diff --git a/API/README.md b/API/README.md index 3668057..a3bbc23 100644 --- a/API/README.md +++ b/API/README.md @@ -1,2 +1,3 @@ # API + The API for Tether (: \ No newline at end of file diff --git a/API/pom.xml b/API/pom.xml index 491d363..ea85852 100644 --- a/API/pom.xml +++ b/API/pom.xml @@ -1,6 +1,6 @@ - 4.0.0 @@ -49,6 +49,19 @@ + + + + + com.konghq + unirest-java-bom + 4.4.0 + pom + import + + + + @@ -91,5 +104,14 @@ 2.11.0 compile + + + com.konghq + unirest-java-core + + + com.konghq + unirest-modules-gson + \ No newline at end of file diff --git a/API/src/main/java/me/braydon/tether/common/DiscordUtils.java b/API/src/main/java/me/braydon/tether/common/DiscordUtils.java new file mode 100644 index 0000000..2a5c611 --- /dev/null +++ b/API/src/main/java/me/braydon/tether/common/DiscordUtils.java @@ -0,0 +1,37 @@ +package me.braydon.tether.common; + +import lombok.NonNull; +import lombok.experimental.UtilityClass; + +/** + * @author Braydon + */ +@UtilityClass +public final class DiscordUtils { + public static final long DISCORD_EPOCH = 1420070400000L; + public static final long TIMESTAMP_OFFSET = 22; + + /** + * Gets the unix creation-time of a Discord-entity by + * doing the reverse snowflake algorithm on its id. + * + * @param entitySnowflake The id of the entity where the creation-time should be determined for + * @return The creation time of the entity as unix time + * @see Credits + */ + public static long getTimeCreated(long entitySnowflake) { + return ((entitySnowflake >>> TIMESTAMP_OFFSET) + DISCORD_EPOCH) / 1000L; + } + + /** + * Get the extension of the + * media with the given id. + * + * @param mediaId the media id + * @return the media extension + */ + @NonNull + public static String getMediaExtension(@NonNull String mediaId) { + return mediaId.startsWith("a_") ? "gif" : "png"; + } +} \ No newline at end of file diff --git a/API/src/main/java/me/braydon/tether/common/EnvironmentUtils.java b/API/src/main/java/me/braydon/tether/common/EnvironmentUtils.java index 8ad865d..3ab642a 100644 --- a/API/src/main/java/me/braydon/tether/common/EnvironmentUtils.java +++ b/API/src/main/java/me/braydon/tether/common/EnvironmentUtils.java @@ -12,6 +12,7 @@ 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")); diff --git a/API/src/main/java/me/braydon/tether/controller/UserController.java b/API/src/main/java/me/braydon/tether/controller/UserController.java index 70f790f..c60fb05 100644 --- a/API/src/main/java/me/braydon/tether/controller/UserController.java +++ b/API/src/main/java/me/braydon/tether/controller/UserController.java @@ -36,8 +36,7 @@ public final class UserController { */ @GetMapping("/{snowflake}") @ResponseBody @NonNull public ResponseEntity getUserBySnowflake(@PathVariable @NonNull String snowflake) - throws BadRequestException, ServiceUnavailableException, ResourceNotFoundException - { + throws BadRequestException, ServiceUnavailableException, ResourceNotFoundException { return ResponseEntity.ok(discordService.getUserBySnowflake(snowflake)); } } \ No newline at end of file diff --git a/API/src/main/java/me/braydon/tether/exception/ExceptionController.java b/API/src/main/java/me/braydon/tether/exception/ExceptionController.java index bd83e13..7fad250 100644 --- a/API/src/main/java/me/braydon/tether/exception/ExceptionController.java +++ b/API/src/main/java/me/braydon/tether/exception/ExceptionController.java @@ -26,7 +26,7 @@ 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( diff --git a/API/src/main/java/me/braydon/tether/model/CachedDiscordUser.java b/API/src/main/java/me/braydon/tether/model/CachedDiscordUser.java deleted file mode 100644 index 9fda83c..0000000 --- a/API/src/main/java/me/braydon/tether/model/CachedDiscordUser.java +++ /dev/null @@ -1,30 +0,0 @@ -package me.braydon.tether.model; - -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NonNull; -import lombok.Setter; -import net.dv8tion.jda.api.entities.User; - -/** - * A model representing a cached Discord user. - * - * @author Braydon - */ -@AllArgsConstructor @Setter @Getter -public final class CachedDiscordUser { - /** - * The cached user. - */ - @NonNull private final User user; - - /** - * The cached user profile. - */ - @NonNull private final User.Profile profile; - - /** - * The unix time of when this user was cached. - */ - private long cached; -} \ No newline at end of file diff --git a/API/src/main/java/me/braydon/tether/model/DiscordUser.java b/API/src/main/java/me/braydon/tether/model/DiscordUser.java deleted file mode 100644 index 36b14a6..0000000 --- a/API/src/main/java/me/braydon/tether/model/DiscordUser.java +++ /dev/null @@ -1,248 +0,0 @@ -package me.braydon.tether.model; - -import lombok.*; -import net.dv8tion.jda.api.OnlineStatus; -import net.dv8tion.jda.api.entities.*; - -import java.util.EnumSet; -import java.util.List; -import java.util.Objects; - -/** - * A model of a Discord user. - * - * @author Braydon - */ -@AllArgsConstructor @Getter @EqualsAndHashCode @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. - */ - @NonNull 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. - */ - @EqualsAndHashCode.Exclude private final SpotifyActivity spotify; - - /** - * Is this user a bot? - */ - private final boolean bot; - - /** - * The unix time of when this user joined Discord. - */ - private final long 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 ? OnlineStatus.OFFLINE : member.getOnlineStatus(); - if (onlineStatus == OnlineStatus.UNKNOWN) { - onlineStatus = OnlineStatus.OFFLINE; - } - - 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().toInstant().toEpochMilli() - ); - } - - /** - * A user's flags. - */ - @AllArgsConstructor @Getter @EqualsAndHashCode - public static class UserFlags { - /** - * The list of flags the user has. - */ - @NonNull private final EnumSet list; - - /** - * The raw flags the user has. - */ - private final int raw; - } - - /** - * A user's avatar. - */ - @AllArgsConstructor @Getter @EqualsAndHashCode - public static class Avatar { - /** - * The id of the user's avatar. - */ - @NonNull private final String id; - - /** - * The URL of the user's avatar. - */ - @NonNull private final String url; - } - - /** - * A user's banner. - */ - @AllArgsConstructor @Getter @EqualsAndHashCode - public static class Banner { - /** - * The id of the user's banner. - */ - @NonNull private final String id; - - /** - * The URL of the user's banner. - */ - @NonNull private final String url; - } - - /** - * A user's Spotify activity data. - */ - @AllArgsConstructor @Getter - public static class SpotifyActivity { - /** - * The ID of the currently playing track. - */ - @NonNull private final String trackId; - - /** - * The name of the currently playing track. - */ - @NonNull private final String song; - - /** - * The currently playing artist. - */ - @NonNull private final String artist; - - /** - * The album the song is from. - */ - @NonNull private final String album; - - /** - * The URL to the art for the currently playing album. - */ - @NonNull private final String albumArtUrl; - - /** - * The URL to the playing track. - */ - @NonNull private final String trackUrl; - - /** - * The current progress of the track (in millis). - */ - private final long trackProgress; - - /** - * The total length of the track (in millis). - */ - private final long trackLength; - - /** - * The unix time of when this track started playing. - */ - private final long started; - - /** - * The unix time of when this track stops playing. - */ - 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 @SuppressWarnings("DataFlowIssue") - public static SpotifyActivity fromActivity(@NonNull RichPresence richPresence) { - String trackUrl = "https://open.spotify.com/track/" + richPresence.getSyncId(); - - // Track progress - 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( - richPresence.getSyncId(), richPresence.getDetails(), richPresence.getState().replace(";", ","), - richPresence.getLargeImage().getText(), richPresence.getLargeImage().getUrl(), trackUrl,trackProgress, - trackLength, started, ends - ); - } - } -} \ No newline at end of file diff --git a/API/src/main/java/me/braydon/tether/model/response/DiscordUserResponse.java b/API/src/main/java/me/braydon/tether/model/response/DiscordUserResponse.java index 2b72e82..1ff60fe 100644 --- a/API/src/main/java/me/braydon/tether/model/response/DiscordUserResponse.java +++ b/API/src/main/java/me/braydon/tether/model/response/DiscordUserResponse.java @@ -3,7 +3,7 @@ package me.braydon.tether.model.response; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NonNull; -import me.braydon.tether.model.DiscordUser; +import me.braydon.tether.model.user.DiscordUser; /** * A response for a successful Discord user request. diff --git a/API/src/main/java/me/braydon/tether/model/user/Banner.java b/API/src/main/java/me/braydon/tether/model/user/Banner.java new file mode 100644 index 0000000..3551889 --- /dev/null +++ b/API/src/main/java/me/braydon/tether/model/user/Banner.java @@ -0,0 +1,40 @@ +package me.braydon.tether.model.user; + +import kong.unirest.core.json.JSONObject; +import lombok.*; +import me.braydon.tether.common.DiscordUtils; + +/** + * A {@link DiscordUser}'s banner. + * + * @author Braydon + */ +@AllArgsConstructor(access = AccessLevel.PRIVATE) @Getter @EqualsAndHashCode +public class Banner { + private static final String BANNER_URL = "https://cdn.discordapp.com/banners/%s/%s.%s"; + + /** + * The id of the user's banner. + */ + @NonNull private final String id; + + /** + * The URL of the user's banner. + */ + @NonNull private final String url; + + /** + * Construct a banner for a user. + * + * @param userSnowflake the snowflake of the user the avatar belongs to + * @param detailsJson the user details json + * @return the constructed banner, if any + */ + protected static Banner fromJson(long userSnowflake, @NonNull JSONObject detailsJson) { + String bannerId = detailsJson.optString("banner", null); + if (bannerId == null) { + return null; + } + return new Banner(bannerId, BANNER_URL.formatted(userSnowflake, bannerId, DiscordUtils.getMediaExtension(bannerId))); + } +} \ No newline at end of file diff --git a/API/src/main/java/me/braydon/tether/model/user/CachedDiscordUser.java b/API/src/main/java/me/braydon/tether/model/user/CachedDiscordUser.java new file mode 100644 index 0000000..f9e2e8e --- /dev/null +++ b/API/src/main/java/me/braydon/tether/model/user/CachedDiscordUser.java @@ -0,0 +1,24 @@ +package me.braydon.tether.model.user; + +import kong.unirest.core.json.JSONObject; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NonNull; + +/** + * A model representing a cached Discord user. + * + * @author Braydon + */ +@AllArgsConstructor @Getter +public final class CachedDiscordUser { + /** + * The Json object for the user's data. + */ + @NonNull private final JSONObject userJson; + + /** + * The unix time of when this user was cached. + */ + private final long cached; +} \ No newline at end of file diff --git a/API/src/main/java/me/braydon/tether/model/user/ConnectedAccount.java b/API/src/main/java/me/braydon/tether/model/user/ConnectedAccount.java new file mode 100644 index 0000000..2a5c9f3 --- /dev/null +++ b/API/src/main/java/me/braydon/tether/model/user/ConnectedAccount.java @@ -0,0 +1,47 @@ +package me.braydon.tether.model.user; + +import kong.unirest.core.json.JSONObject; +import lombok.*; + +/** + * A linked connection to a {@link DiscordUser}. + * + * @author Braydon + */ +@AllArgsConstructor(access = AccessLevel.PRIVATE) @Getter @EqualsAndHashCode +public class ConnectedAccount { + /** + * The id of this account. + */ + @NonNull private final String id; + + /** + * The type of this account. + */ + @NonNull private final String type; + + /** + * The name of this account. + */ + @NonNull private final String name; + + /** + * Whether this account is verified. + */ + private final boolean verified; + + /** + * Construct a connected account for a user. + * + * @param accountJson the connected account json + * @return the constructed account + */ + @NonNull + protected static ConnectedAccount fromJson(@NonNull JSONObject accountJson) { + String id = accountJson.getString("id"); + String type = accountJson.getString("type"); + String name = accountJson.getString("name"); + boolean verified = accountJson.getBoolean("verified"); + return new ConnectedAccount(id, type, name, verified); + } +} \ No newline at end of file diff --git a/API/src/main/java/me/braydon/tether/model/user/DiscordUser.java b/API/src/main/java/me/braydon/tether/model/user/DiscordUser.java new file mode 100644 index 0000000..7c6323e --- /dev/null +++ b/API/src/main/java/me/braydon/tether/model/user/DiscordUser.java @@ -0,0 +1,210 @@ +package me.braydon.tether.model.user; + +import kong.unirest.core.json.JSONArray; +import kong.unirest.core.json.JSONObject; +import lombok.*; +import me.braydon.tether.common.DiscordUtils; +import me.braydon.tether.model.user.avatar.Avatar; +import me.braydon.tether.model.user.avatar.AvatarDecoration; +import me.braydon.tether.model.user.clan.Clan; +import me.braydon.tether.model.user.nitro.NitroSubscription; +import net.dv8tion.jda.api.OnlineStatus; +import net.dv8tion.jda.api.entities.Activity; +import net.dv8tion.jda.api.entities.ClientType; +import net.dv8tion.jda.api.entities.Member; + +import java.util.*; + +/** + * A model of a Discord user. + *

+ * This model will contain ALL + * data returned from Discord. + *

+ * + * @author Braydon + */ +@AllArgsConstructor @Getter @EqualsAndHashCode @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 user's discriminator, 0 if not legacy. + */ + private final int discriminator; + + /** + * The flags of this user. + */ + @NonNull private final UserFlags flags; + + /** + * The avatar of this user. + */ + @NonNull private final Avatar avatar; + + /** + * The avatar decoration of this user, if any. + */ + private final AvatarDecoration avatarDecoration; + + /** + * The banner of this user, if any. + */ + private final Banner banner; + + /** + * The banner color (hex) of this user, if any. + */ + private final String bannerColor; + + /** + * The user's bio, if any. + */ + private final String bio; + + /** + * The accent color (hex) of this user. + */ + @NonNull private final String accentColor; + + /** + * The online status of this user. + */ + @NonNull private final OnlineStatus onlineStatus; + + /** + * The clients this user is active on, if known. + */ + @NonNull private final EnumSet activeClients; + + /** + * The activities of this user, if known. + */ + private final List activities; + + /** + * The Spotify activity of this user, if known. + */ + @EqualsAndHashCode.Exclude private final SpotifyActivity spotify; + + /** + * The connected accounts of this user. + */ + @NonNull private final Set connectedAccounts; + + /** + * The clan this user is in, if any. + */ + private final Clan clan; + + /** + * This user's Nitro subscription, if any. + */ + private final NitroSubscription nitroSubscription; + + /** + * Is this user a bot? + */ + private final boolean bot; + + /** + * Whether this user is a legacy user. + *

+ * A user is "legacy" if they haven't yet + * moved to the new username system and got + * rid of their discriminator. + *

+ */ + private final boolean legacy; + + /** + * The unix time of when this user joined Discord. + */ + private final long createdAt; + + /** + * Construct a Discord user from the + * raw entities returned from Discord. + * + * @param userJson the Json object for the user's data + * @param member the raw member entity, if any + * @return the constructed user + */ + @NonNull + public static DiscordUser buildFromEntity(@NonNull JSONObject userJson, Member member) { + JSONObject detailsJson = userJson.has("user") ? userJson.getJSONObject("user") : userJson; + + long snowflake = Long.parseLong(detailsJson.getString("id")); + String username = detailsJson.getString("username"); + String displayName = detailsJson.optString("global_name", null); + int discriminator = Integer.parseInt(detailsJson.getString("discriminator")); + boolean isUserLegacy = discriminator > 0; + UserFlags flags = UserFlags.fromJson(detailsJson); + + Avatar avatar = Avatar.fromJson(snowflake, discriminator, isUserLegacy, detailsJson); + AvatarDecoration avatarDecoration = detailsJson.isNull("avatar_decoration_data") ? null + : AvatarDecoration.fromJson(detailsJson.getJSONObject("avatar_decoration_data")); + Banner banner = Banner.fromJson(snowflake, detailsJson); + String bannerColor = detailsJson.optString("banner_color", null); + + String bio = detailsJson.optString("bio", null); + String accentColor = String.format("#%06X", detailsJson.isNull("accent_color") ? 0xFFFFFF + : 0xFFFFFF & detailsJson.getInt("accent_color")); + Clan clan = detailsJson.isNull("clan") ? null : Clan.fromJson(detailsJson.getJSONObject("clan")); + NitroSubscription nitroSubscription = userJson.isNull("premium_type") + || userJson.isNull("premium_since") ? null : NitroSubscription.fromJson(userJson); + + boolean bot = detailsJson.optBoolean("bot", false); + long created = DiscordUtils.getTimeCreated(snowflake); + + // Get the user's online status + OnlineStatus onlineStatus = member == null ? OnlineStatus.OFFLINE : member.getOnlineStatus(); + if (onlineStatus == OnlineStatus.UNKNOWN) { + onlineStatus = OnlineStatus.OFFLINE; + } + + // Get the user's active clients and activities + EnumSet activeClients = member == null ? EnumSet.noneOf(ClientType.class) : 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; + } + } + + // Get the user's connected accounts + Set connectedAccounts = new HashSet<>(); + if (userJson.has("connected_accounts")) { + JSONArray accounts = userJson.getJSONArray("connected_accounts"); + for (int i = 0; i < accounts.length(); i++) { + connectedAccounts.add(ConnectedAccount.fromJson(accounts.getJSONObject(i))); + } + } + + // Finally return the constructed user + return new DiscordUser( + snowflake, username, displayName, discriminator, flags, avatar, avatarDecoration, banner, bannerColor, bio, + accentColor, onlineStatus, activeClients, activities, spotify, connectedAccounts, clan, nitroSubscription, + bot, isUserLegacy, created + ); + } +} \ No newline at end of file diff --git a/API/src/main/java/me/braydon/tether/model/user/SpotifyActivity.java b/API/src/main/java/me/braydon/tether/model/user/SpotifyActivity.java new file mode 100644 index 0000000..a763646 --- /dev/null +++ b/API/src/main/java/me/braydon/tether/model/user/SpotifyActivity.java @@ -0,0 +1,92 @@ +package me.braydon.tether.model.user; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NonNull; +import net.dv8tion.jda.api.entities.RichPresence; + +import java.util.Objects; + +/** + * The Spotify activity data + * of a {@link DiscordUser}. + * + * @author Braydon + */ +@AllArgsConstructor(access = AccessLevel.PRIVATE) @Getter +public class SpotifyActivity { + /** + * The ID of the currently playing track. + */ + @NonNull private final String trackId; + + /** + * The name of the currently playing track. + */ + @NonNull private final String song; + + /** + * The currently playing artist. + */ + @NonNull private final String artist; + + /** + * The album the song is from. + */ + @NonNull private final String album; + + /** + * The URL to the art for the currently playing album. + */ + @NonNull private final String albumArtUrl; + + /** + * The URL to the playing track. + */ + @NonNull private final String trackUrl; + + /** + * The current progress of the track (in millis). + */ + private final long trackProgress; + + /** + * The total length of the track (in millis). + */ + private final long trackLength; + + /** + * The unix time of when this track started playing. + */ + private final long started; + + /** + * The unix time of when this track stops playing. + */ + private final long ends; + + /** + * Construct a Spotify activity for a user. + * + * @param richPresence the raw Discord data + * @return the constructed activity + */ + @NonNull @SuppressWarnings("DataFlowIssue") + protected static SpotifyActivity fromActivity(@NonNull RichPresence richPresence) { + String trackUrl = "https://open.spotify.com/track/" + richPresence.getSyncId(); + + // Track progress + 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( + richPresence.getSyncId(), richPresence.getDetails(), richPresence.getState().replace(";", ","), + richPresence.getLargeImage().getText(), richPresence.getLargeImage().getUrl(), trackUrl, trackProgress, + trackLength, started, ends + ); + } +} \ No newline at end of file diff --git a/API/src/main/java/me/braydon/tether/model/user/UserFlags.java b/API/src/main/java/me/braydon/tether/model/user/UserFlags.java new file mode 100644 index 0000000..27f4195 --- /dev/null +++ b/API/src/main/java/me/braydon/tether/model/user/UserFlags.java @@ -0,0 +1,37 @@ +package me.braydon.tether.model.user; + +import kong.unirest.core.json.JSONObject; +import lombok.*; +import net.dv8tion.jda.api.entities.User; + +import java.util.EnumSet; + +/** + * The flag's of a {@link DiscordUser}. + * + * @author Braydon + */ +@AllArgsConstructor(access = AccessLevel.PRIVATE) @Getter @EqualsAndHashCode +public class UserFlags { + /** + * The list of flags the user has. + */ + @NonNull private final EnumSet list; + + /** + * The raw flags the user has. + */ + private final int raw; + + /** + * Construct the flags for a user. + * + * @param detailsJson the user details json + * @return the constructed flags + */ + @NonNull + protected static UserFlags fromJson(@NonNull JSONObject detailsJson) { + int flags = detailsJson.getInt("flags"); + return new UserFlags(User.UserFlag.getFlags(flags), flags); + } +} \ No newline at end of file diff --git a/API/src/main/java/me/braydon/tether/model/user/avatar/Avatar.java b/API/src/main/java/me/braydon/tether/model/user/avatar/Avatar.java new file mode 100644 index 0000000..47f2bfe --- /dev/null +++ b/API/src/main/java/me/braydon/tether/model/user/avatar/Avatar.java @@ -0,0 +1,49 @@ +package me.braydon.tether.model.user.avatar; + +import kong.unirest.core.json.JSONObject; +import lombok.*; +import me.braydon.tether.common.DiscordUtils; +import me.braydon.tether.model.user.DiscordUser; + +/** + * A {@link DiscordUser}'s avatar. + * + * @author Braydon + */ +@AllArgsConstructor(access = AccessLevel.PRIVATE) @Getter @EqualsAndHashCode +public class Avatar { + private static final String DEFAULT_AVATAR_URL = "https://cdn.discordapp.com/embed/avatars/%s.png"; + private static final String AVATAR_URL = "https://cdn.discordapp.com/avatars/%s/%s.%s"; + + /** + * The id of the user's avatar. + */ + @NonNull private final String id; + + /** + * The URL of the user's avatar. + */ + @NonNull private final String url; + + /** + * Construct an avatar for a user. + * + * @param userSnowflake the snowflake of the user the avatar belongs to + * @param userDiscriminator the snowflake of the user the avatar belongs to + * @param isUserLegacy whether the user is legacy + * @param detailsJson the user details json + * @return the constructed avatar + */ + @NonNull + public static Avatar fromJson(long userSnowflake, int userDiscriminator, boolean isUserLegacy, @NonNull JSONObject detailsJson) { + String avatarId = detailsJson.getString("avatar"); + String avatarUrl; + if (avatarId == null) { // Fallback to the default avatar + avatarId = String.valueOf(isUserLegacy ? userDiscriminator % 5 : (userDiscriminator >> 22) % 6); + avatarUrl = DEFAULT_AVATAR_URL.formatted(avatarId); + } else { + avatarUrl = AVATAR_URL.formatted(userSnowflake, avatarId, DiscordUtils.getMediaExtension(avatarId)); + } + return new Avatar(avatarId, avatarUrl); + } +} \ No newline at end of file diff --git a/API/src/main/java/me/braydon/tether/model/user/avatar/AvatarDecoration.java b/API/src/main/java/me/braydon/tether/model/user/avatar/AvatarDecoration.java new file mode 100644 index 0000000..7431cd2 --- /dev/null +++ b/API/src/main/java/me/braydon/tether/model/user/avatar/AvatarDecoration.java @@ -0,0 +1,43 @@ +package me.braydon.tether.model.user.avatar; + +import kong.unirest.core.json.JSONObject; +import lombok.*; +import me.braydon.tether.model.user.DiscordUser; + +/** + * The avatar decoration + * of a {@link DiscordUser}. + * + * @author Braydon + */ +@AllArgsConstructor(access = AccessLevel.PRIVATE) @Getter @EqualsAndHashCode +public class AvatarDecoration { + /** + * The asset of this decoration. + */ + @NonNull private final DecorationAsset asset; + + /** + * The id of the decoration sku. + */ + @NonNull private final String skuId; + + /** + * The unix time of when this decorations expires, null if permanent. + */ + private final Long expires; + + /** + * Construct an avatar decoration for a user. + * + * @param decorationJson the decoration json + * @return the constructed decoration + */ + @NonNull + public static AvatarDecoration fromJson(@NonNull JSONObject decorationJson) { + DecorationAsset asset = DecorationAsset.fromJson(decorationJson); + String skuId = decorationJson.getString("sku_id"); + long expires = decorationJson.optLong("expires_at", -1L); + return new AvatarDecoration(asset, skuId, expires == -1L ? null : expires); + } +} \ No newline at end of file diff --git a/API/src/main/java/me/braydon/tether/model/user/avatar/DecorationAsset.java b/API/src/main/java/me/braydon/tether/model/user/avatar/DecorationAsset.java new file mode 100644 index 0000000..e946e1e --- /dev/null +++ b/API/src/main/java/me/braydon/tether/model/user/avatar/DecorationAsset.java @@ -0,0 +1,37 @@ +package me.braydon.tether.model.user.avatar; + +import kong.unirest.core.json.JSONObject; +import lombok.*; +import me.braydon.tether.model.user.DiscordUser; + +/** + * A {@link DiscordUser}'s avatar. + * + * @author Braydon + */ +@AllArgsConstructor(access = AccessLevel.PRIVATE) @Getter @EqualsAndHashCode +public class DecorationAsset { + private static final String DECORATION_URL = "https://cdn.discordapp.com/avatar-decoration-presets/%s.png"; + + /** + * The id of the clan badge. + */ + @NonNull private final String id; + + /** + * The URL of the clan badge. + */ + @NonNull private final String url; + + /** + * Construct an avatar decoration asset for a user. + * + * @param decorationJson the clan json + * @return the constructed asset + */ + @NonNull + protected static DecorationAsset fromJson(@NonNull JSONObject decorationJson) { + String badgeId = decorationJson.getString("asset"); + return new DecorationAsset(badgeId, DECORATION_URL.formatted(badgeId)); + } +} \ No newline at end of file diff --git a/API/src/main/java/me/braydon/tether/model/user/clan/Badge.java b/API/src/main/java/me/braydon/tether/model/user/clan/Badge.java new file mode 100644 index 0000000..aeb4e41 --- /dev/null +++ b/API/src/main/java/me/braydon/tether/model/user/clan/Badge.java @@ -0,0 +1,39 @@ +package me.braydon.tether.model.user.clan; + +import kong.unirest.core.json.JSONObject; +import lombok.*; +import me.braydon.tether.common.DiscordUtils; +import me.braydon.tether.model.user.DiscordUser; + +/** + * A {@link DiscordUser}'s avatar. + * + * @author Braydon + */ +@AllArgsConstructor(access = AccessLevel.PRIVATE) @Getter @EqualsAndHashCode +public class Badge { + private static final String CLAN_BADGE_URL = "https://cdn.discordapp.com/clan-badges/%s/%s.%s"; + + /** + * The id of the clan badge. + */ + @NonNull private final String id; + + /** + * The URL of the clan badge. + */ + @NonNull private final String url; + + /** + * Construct a badge for a clan. + * + * @param userSnowflake the user's snowflake + * @param clanJson the clan json + * @return the constructed clan badge + */ + @NonNull + protected static Badge fromJson(long userSnowflake, @NonNull JSONObject clanJson) { + String badgeId = clanJson.getString("badge"); + return new Badge(badgeId, CLAN_BADGE_URL.formatted(userSnowflake, badgeId, DiscordUtils.getMediaExtension(badgeId))); + } +} \ No newline at end of file diff --git a/API/src/main/java/me/braydon/tether/model/user/clan/Clan.java b/API/src/main/java/me/braydon/tether/model/user/clan/Clan.java new file mode 100644 index 0000000..de38bc3 --- /dev/null +++ b/API/src/main/java/me/braydon/tether/model/user/clan/Clan.java @@ -0,0 +1,48 @@ +package me.braydon.tether.model.user.clan; + +import kong.unirest.core.json.JSONObject; +import lombok.*; +import me.braydon.tether.model.user.DiscordUser; + +/** + * The clan of a {@link DiscordUser}. + * + * @author Braydon + */ +@AllArgsConstructor(access = AccessLevel.PRIVATE) @Getter @EqualsAndHashCode +public class Clan { + /** + * The snowflake of the Guild this clan belongs to. + */ + private final long guildSnowflake; + + /** + * The tag of this clan. + */ + @NonNull private final String tag; + + /** + * The badge for this clan. + */ + @NonNull private final Badge badge; + + /** + * Whether the identity is enabled for this clan. + */ + private final boolean identityEnabled; + + /** + * Construct a clan for a user. + * + * @param clanJson the user details json + * @return the constructed clan + */ + @NonNull + public static Clan fromJson(@NonNull JSONObject clanJson) { + long snowflake = Long.parseLong(clanJson.getString("identity_guild_id")); + String tag = clanJson.getString("tag"); + Badge badge = Badge.fromJson(snowflake, clanJson); + boolean identityEnabled = clanJson.getBoolean("identity_enabled"); + return new Clan(snowflake, tag, badge, identityEnabled); + } +} \ No newline at end of file diff --git a/API/src/main/java/me/braydon/tether/model/user/nitro/NitroSubscription.java b/API/src/main/java/me/braydon/tether/model/user/nitro/NitroSubscription.java new file mode 100644 index 0000000..87b7581 --- /dev/null +++ b/API/src/main/java/me/braydon/tether/model/user/nitro/NitroSubscription.java @@ -0,0 +1,38 @@ +package me.braydon.tether.model.user.nitro; + +import kong.unirest.core.json.JSONObject; +import lombok.*; +import me.braydon.tether.model.user.DiscordUser; + +import java.time.ZonedDateTime; + +/** + * A Nitro subscription for a {@link DiscordUser}. + * + * @author Braydon + */ +@AllArgsConstructor(access = AccessLevel.PRIVATE) @Getter @EqualsAndHashCode +public class NitroSubscription { + /** + * The type of this subscription. + */ + @NonNull private final NitroType type; + + /** + * The unix time this subscription was started. + */ + private final long subscribed; + + /** + * Construct a Nitro subscription for a user. + * + * @param userJson the user json + * @return the constructed subscription + */ + @NonNull + public static NitroSubscription fromJson(@NonNull JSONObject userJson) { + NitroType type = NitroType.byType(userJson.getInt("premium_type")); + long subscribed = ZonedDateTime.parse(userJson.getString("premium_since")).toInstant().toEpochMilli(); + return new NitroSubscription(type, subscribed); + } +} \ No newline at end of file diff --git a/API/src/main/java/me/braydon/tether/model/user/nitro/NitroType.java b/API/src/main/java/me/braydon/tether/model/user/nitro/NitroType.java new file mode 100644 index 0000000..dfb4403 --- /dev/null +++ b/API/src/main/java/me/braydon/tether/model/user/nitro/NitroType.java @@ -0,0 +1,29 @@ +package me.braydon.tether.model.user.nitro; + +import lombok.NonNull; + +/** + * Different types of Nitro subscriptions. + * + * @author Braydon + */ +public enum NitroType { + CLASSIC, NITRO, BASIC, UNKNOWN; + + /** + * Get the nitro type + * from the given raw type. + * + * @param type the raw type + * @return the nitro type + */ + @NonNull + public static NitroType byType(int type) { + for (NitroType nitroType : values()) { + if (type == nitroType.ordinal() + 1) { + return nitroType; + } + } + return UNKNOWN; + } +} \ No newline at end of file diff --git a/API/src/main/java/me/braydon/tether/packet/PacketRegistry.java b/API/src/main/java/me/braydon/tether/packet/PacketRegistry.java index d770f8d..80a5060 100644 --- a/API/src/main/java/me/braydon/tether/packet/PacketRegistry.java +++ b/API/src/main/java/me/braydon/tether/packet/PacketRegistry.java @@ -17,6 +17,7 @@ public final class PacketRegistry { * A registry of packets, identified by their op code. */ private static final Map> REGISTRY = Collections.synchronizedMap(new HashMap<>()); + static { register(OpCode.LISTEN_TO_USER, ListenToUserPacket.class); register(OpCode.USER_STATUS, UserStatusPacket.class); diff --git a/API/src/main/java/me/braydon/tether/packet/impl/websocket/user/UserStatusPacket.java b/API/src/main/java/me/braydon/tether/packet/impl/websocket/user/UserStatusPacket.java index 5780a39..bcb17d4 100644 --- a/API/src/main/java/me/braydon/tether/packet/impl/websocket/user/UserStatusPacket.java +++ b/API/src/main/java/me/braydon/tether/packet/impl/websocket/user/UserStatusPacket.java @@ -1,7 +1,7 @@ package me.braydon.tether.packet.impl.websocket.user; import lombok.NonNull; -import me.braydon.tether.model.DiscordUser; +import me.braydon.tether.model.user.DiscordUser; import me.braydon.tether.packet.OpCode; import me.braydon.tether.packet.Packet; diff --git a/API/src/main/java/me/braydon/tether/service/DiscordService.java b/API/src/main/java/me/braydon/tether/service/DiscordService.java index ba6b19b..17027b2 100644 --- a/API/src/main/java/me/braydon/tether/service/DiscordService.java +++ b/API/src/main/java/me/braydon/tether/service/DiscordService.java @@ -3,22 +3,31 @@ package me.braydon.tether.service; import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; import jakarta.annotation.PostConstruct; +import kong.unirest.core.HttpResponse; +import kong.unirest.core.HttpStatus; +import kong.unirest.core.JsonNode; +import kong.unirest.core.Unirest; +import kong.unirest.core.json.JSONObject; 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.CachedDiscordUser; -import me.braydon.tether.model.DiscordUser; import me.braydon.tether.model.response.DiscordUserResponse; +import me.braydon.tether.model.user.CachedDiscordUser; +import me.braydon.tether.model.user.DiscordUser; import net.dv8tion.jda.api.JDA; import net.dv8tion.jda.api.JDABuilder; -import net.dv8tion.jda.api.entities.*; +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.SelfUser; 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.http.HttpHeaders; import org.springframework.stereotype.Service; import java.util.concurrent.TimeUnit; @@ -32,14 +41,6 @@ import java.util.concurrent.TimeUnit; @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; - /** * A cache of users retrieved from Discord. */ @@ -47,6 +48,17 @@ public final class DiscordService { .expireAfterAccess(3L, TimeUnit.MINUTES) .build(); + @Value("${discord.bot-token}") + private String botToken; + + @Value("${discord.user-account-token}") + private String userAccountToken; + + /** + * The current instance of the Discord bot. + */ + private JDA jda; + @PostConstruct public void onInitialize() { connectBot(); @@ -58,7 +70,7 @@ public final class DiscordService { * @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 + * @throws ResourceNotFoundException if the user is not found */ @NonNull public DiscordUserResponse getUserBySnowflake(@NonNull String rawSnowflake) throws BadRequestException, ServiceUnavailableException, ResourceNotFoundException { @@ -66,7 +78,7 @@ public final class DiscordService { try { snowflake = Long.parseLong(rawSnowflake); } catch (NumberFormatException ex) { - throw new BadRequestException("Not a valid snowflake"); + throw new BadRequestException("Not a valid snowflake."); } return getUserBySnowflake(snowflake); } @@ -77,7 +89,7 @@ public final class DiscordService { * @param snowflake the user snowflake * @return the user response * @throws ServiceUnavailableException if the bot is not connected - * @throws ResourceNotFoundException if the user is not found + * @throws ResourceNotFoundException if the user is not found */ @NonNull public DiscordUserResponse getUserBySnowflake(long snowflake) throws BadRequestException, ServiceUnavailableException, ResourceNotFoundException { @@ -91,15 +103,13 @@ public final class DiscordService { CachedDiscordUser cachedUser = cachedUsers.getIfPresent(snowflake); boolean fromCache = cachedUser != null; if (cachedUser == null) { // No cache, retrieve fresh data - User user = jda.retrieveUserById(snowflake).complete(); - cachedUser = new CachedDiscordUser( - user, user.retrieveProfile().complete(), System.currentTimeMillis() - ); + cachedUser = new CachedDiscordUser(getUser(snowflake, member != null), System.currentTimeMillis()); cachedUsers.put(snowflake, cachedUser); } + // Finally build the response and respond with it return new DiscordUserResponse( - DiscordUser.buildFromEntity(cachedUser.getUser(), cachedUser.getProfile(), member), + DiscordUser.buildFromEntity(cachedUser.getUserJson(), member), fromCache ? cachedUser.getCached() : -1L ); } catch (ErrorResponseException ex) { @@ -133,6 +143,25 @@ public final class DiscordService { ); } + /** + * Get the user with the given snowflake from Discord. + * + * @param snowflake the user snowflake + * @param includeProfile whether to include the user's profile + * @return the user json object + * @throws BadRequestException if the request fails + */ + @NonNull + private JSONObject getUser(long snowflake, boolean includeProfile) throws BadRequestException { + HttpResponse response = Unirest.get("https://discord.com/api/v10/users/" + snowflake + (includeProfile ? "/profile?with_mutual_guilds=false" : "")) + .header(HttpHeaders.AUTHORIZATION, userAccountToken).asJson(); + JSONObject json = response.getBody().getObject(); + if (response.getStatus() == HttpStatus.OK) { + return json; + } + throw new BadRequestException(json.getInt("code") + ": " + json.getString("message")); + } + /** * Get a member from a guild by their snowflake. * @@ -148,6 +177,7 @@ public final class DiscordService { } } } catch (ErrorResponseException ex) { + // Ignore if the member is not in the guild if (ex.getErrorCode() != 10007) { throw ex; } diff --git a/API/src/main/java/me/braydon/tether/service/websocket/WebSocket.java b/API/src/main/java/me/braydon/tether/service/websocket/WebSocket.java index 72fc95a..4020ee5 100644 --- a/API/src/main/java/me/braydon/tether/service/websocket/WebSocket.java +++ b/API/src/main/java/me/braydon/tether/service/websocket/WebSocket.java @@ -9,7 +9,7 @@ import me.braydon.tether.config.AppConfig; 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.user.DiscordUser; import me.braydon.tether.packet.Packet; import me.braydon.tether.packet.PacketRegistry; import me.braydon.tether.packet.impl.websocket.misc.ErrorMessagePacket; @@ -147,7 +147,7 @@ public class WebSocket extends TextWebSocketHandler { * Send a packet to the given session. * * @param session the session to send to - * @param packet the packet to send + * @param packet the packet to send */ @SneakyThrows private void dispatch(@NonNull WebSocketSession session, @NonNull Packet packet) { diff --git a/API/src/main/java/me/braydon/tether/service/websocket/WebSocketClient.java b/API/src/main/java/me/braydon/tether/service/websocket/WebSocketClient.java index f8147ab..1ea1713 100644 --- a/API/src/main/java/me/braydon/tether/service/websocket/WebSocketClient.java +++ b/API/src/main/java/me/braydon/tether/service/websocket/WebSocketClient.java @@ -3,7 +3,7 @@ package me.braydon.tether.service.websocket; import lombok.Getter; import lombok.NonNull; import lombok.Setter; -import me.braydon.tether.model.DiscordUser; +import me.braydon.tether.model.user.DiscordUser; import org.springframework.web.socket.WebSocketSession; /** @@ -18,13 +18,15 @@ public class WebSocketClient { * The session this client is for. */ @NonNull private final WebSocketSession session; - + /** + * The unix time this client connected. + */ + private final long connected; /** * The snowflake of the user this client * is listening to for updates, if any. */ private Long listeningTo; - /** * The last user this client * has been sent a status for. @@ -35,11 +37,6 @@ public class WebSocketClient { */ private DiscordUser lastUser; - /** - * The unix time this client connected. - */ - private final long connected; - protected WebSocketClient(@NonNull WebSocketSession session) { this.session = session; connected = System.currentTimeMillis(); diff --git a/API/src/main/resources/application.yml b/API/src/main/resources/application.yml index 6d1bf5e..e195fac 100644 --- a/API/src/main/resources/application.yml +++ b/API/src/main/resources/application.yml @@ -10,6 +10,10 @@ logging: # Discord Configuration discord: + # The user account token also for general API calls + user-account-token: "CHANGE_ME" + + # The bot token for realtime API calls (online status, activities, etc) bot-token: "CHANGE_ME" # Spring Configuration diff --git a/README.md b/README.md index f20f6b1..c1a9996 100644 --- a/README.md +++ b/README.md @@ -4,4 +4,4 @@ An API designed to provide real-time access to a user's Discord data. ## TODO - [x] Caching - [x] WebSockets -- [ ] User account for extra data? (about me, connections, etc) \ No newline at end of file +- [x] User account for extra data? (about me, connections, etc) \ No newline at end of file