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