com.jagrosh.discordipc.IPCClient Maven / Gradle / Ivy
/*
* Copyright 2017 John Grosh ([email protected]).
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.jagrosh.discordipc;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import com.jagrosh.discordipc.entities.*;
import com.jagrosh.discordipc.entities.Packet.OpCode;
import com.jagrosh.discordipc.entities.pipe.Pipe;
import com.jagrosh.discordipc.entities.pipe.PipeStatus;
import com.jagrosh.discordipc.exceptions.NoDiscordClientException;
import com.jagrosh.discordipc.impl.Backoff;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.Closeable;
import java.io.IOException;
import java.lang.management.ManagementFactory;
import java.util.HashMap;
/**
* Represents a Discord IPC Client that can send and receive
* Rich Presence data.
*
* The ID provided should be the client ID of the particular
* application providing Rich Presence, which can be found
* here.
*
* When initially created using {@link #IPCClient(long, boolean, String)} the client will
* be inactive awaiting a call to {@link #connect(DiscordBuild...)}.
* After the call, this client can send and receive Rich Presence data
* to and from discord via {@link #sendRichPresence(RichPresence)} and
* {@link #setListener(IPCListener)} respectively.
*
* Please be mindful that the client created is initially unconnected,
* and calling any methods that exchange data between this client and
* Discord before a call to {@link #connect(DiscordBuild...)} will cause
* an {@link IllegalStateException} to be thrown.
* This also means that the IPCClient cannot tell whether the client ID
* provided is valid or not before a handshake.
*
* @author John Grosh ([email protected])
*/
public final class IPCClient implements Closeable {
private static final Logger LOGGER = LoggerFactory.getLogger(IPCClient.class);
private final Backoff RECONNECT_TIME_MS = new Backoff(500, 60 * 1000);
private final long clientId;
private final boolean autoRegister;
private final HashMap callbacks = new HashMap<>();
private final String applicationId, optionalSteamId;
private volatile Pipe pipe;
private Logger forcedLogger = null;
private IPCListener listener = null;
private Thread readThread = null;
private String encoding = "UTF-8";
private long nextDelay = 0L;
private boolean debugMode;
private boolean verboseLogging;
/**
* Constructs a new IPCClient using the provided {@code clientId}.
* This is initially unconnected to Discord.
*
* @param clientId The Rich Presence application's client ID, which can be found
* here
* @param debugMode Whether Debug Logging should be shown for this client
* @param verboseLogging Whether excess/deeper-rooted logging should be shown
* @param autoRegister Whether to register as an application with discord
* @param applicationId The application id to register with, usually the client id in string form
* @param optionalSteamId The steam id to register with, registers as a steam game if present
*/
public IPCClient(long clientId, boolean debugMode, boolean verboseLogging, boolean autoRegister, String applicationId, String optionalSteamId) {
this.clientId = clientId;
this.debugMode = debugMode;
this.verboseLogging = verboseLogging;
this.applicationId = applicationId;
this.autoRegister = autoRegister;
this.optionalSteamId = optionalSteamId;
}
/**
* Constructs a new IPCClient using the provided {@code clientId}.
* This is initially unconnected to Discord.
*
* @param clientId The Rich Presence application's client ID, which can be found
* here
* @param debugMode Whether Debug Logging should be shown for this client
* @param verboseLogging Whether excess/deeper-rooted logging should be shown
* @param autoRegister Whether to register as an application with discord
* @param applicationId The application id to register with, usually the client id in string form
*/
public IPCClient(long clientId, boolean debugMode, boolean verboseLogging, boolean autoRegister, String applicationId) {
this(clientId, debugMode, verboseLogging, autoRegister, applicationId, null);
}
/**
* Constructs a new IPCClient using the provided {@code clientId}.
* This is initially unconnected to Discord.
*
* @param clientId The Rich Presence application's client ID, which can be found
* here
* @param debugMode Whether Debug Logging should be shown for this client
* @param verboseLogging Whether excess/deeper-rooted logging should be shown
*/
public IPCClient(long clientId, boolean debugMode, boolean verboseLogging) {
this(clientId, debugMode, verboseLogging, false, null);
}
/**
* Constructs a new IPCClient using the provided {@code clientId}.
* This is initially unconnected to Discord.
*
* @param clientId The Rich Presence application's client ID, which can be found
* here
* @param debugMode Whether Debug Logging should be shown for this client
* @param autoRegister Whether to register as an application with discord
* @param applicationId The application id to register with, usually the client id in string form
* @param optionalSteamId The steam id to register with, registers as a steam game if present
*/
public IPCClient(long clientId, boolean debugMode, boolean autoRegister, String applicationId, String optionalSteamId) {
this(clientId, debugMode, false, autoRegister, applicationId, optionalSteamId);
}
/**
* Constructs a new IPCClient using the provided {@code clientId}.
* This is initially unconnected to Discord.
*
* @param clientId The Rich Presence application's client ID, which can be found
* here
* @param debugMode Whether Debug Logging should be shown for this client
* @param autoRegister Whether to register as an application with discord
* @param applicationId The application id to register with, usually the client id in string form
*/
public IPCClient(long clientId, boolean debugMode, boolean autoRegister, String applicationId) {
this(clientId, debugMode, autoRegister, applicationId, null);
}
/**
* Constructs a new IPCClient using the provided {@code clientId}.
* This is initially unconnected to Discord.
*
* @param clientId The Rich Presence application's client ID, which can be found
* here
* @param debugMode Whether Debug Logging should be shown for this client
*/
public IPCClient(long clientId, boolean debugMode) {
this(clientId, debugMode, false, null);
}
/**
* Constructs a new IPCClient using the provided {@code clientId}.
* This is initially unconnected to Discord.
*
* @param clientId The Rich Presence application's client ID, which can be found
* here
* @param autoRegister Whether to register as an application with discord
* @param applicationId The application id to register with, usually the client id in string form
* @param optionalSteamId The steam id to register with, registers as a steam game if present
*/
public IPCClient(long clientId, boolean autoRegister, String applicationId, String optionalSteamId) {
this(clientId, false, autoRegister, applicationId, optionalSteamId);
}
/**
* Constructs a new IPCClient using the provided {@code clientId}.
* This is initially unconnected to Discord.
*
* @param clientId The Rich Presence application's client ID, which can be found
* here
* @param autoRegister Whether to register as an application with discord
* @param applicationId The application id to register with, usually the client id in string form
*/
public IPCClient(long clientId, boolean autoRegister, String applicationId) {
this(clientId, autoRegister, applicationId, null);
}
/**
* Constructs a new IPCClient using the provided {@code clientId}.
* This is initially unconnected to Discord.
*
* @param clientId The Rich Presence application's client ID, which can be found
* here
*/
public IPCClient(long clientId) {
this(clientId, false, null);
}
/**
* Finds the current process ID.
*
* @return The current process ID.
*/
private static int getPID() {
String pr = ManagementFactory.getRuntimeMXBean().getName();
return Integer.parseInt(pr.substring(0, pr.indexOf('@')));
}
/**
* Retrieves the current logger that should be used
*
* @param instance The logger instance
* @return the current logger to use
*/
public Logger getCurrentLogger(final Logger instance) {
return forcedLogger != null ? forcedLogger : instance;
}
/**
* Sets the current logger that should be used
*
* @param forcedLogger The logger instance to be used
*/
public void setForcedLogger(Logger forcedLogger) {
this.forcedLogger = forcedLogger;
}
/**
* Sets this IPCClient's {@link IPCListener} to handle received events.
*
* A single IPCClient can only have one of these set at any given time.
* Setting this {@code null} will remove the currently active one.
*
* This can be set safely before a call to {@link #connect(DiscordBuild...)}
* is made.
*
* @param listener The {@link IPCListener} to set for this IPCClient.
* @see IPCListener
*/
public void setListener(IPCListener listener) {
this.listener = listener;
if (pipe != null)
pipe.setListener(listener);
}
/**
* Gets the application id associated with this IPCClient
*
* This must be set upon initialization and is a required variable
*
* @return applicationId
*/
public String getApplicationId() {
return applicationId;
}
/**
* Gets the steam id associated with this IPCClient, if any
*
* This must be set upon initialization and is an optional variable
* If set and autoRegister is true, then this client will register as a steam game
*
* @return optionalSteamId
*/
public String getOptionalSteamId() {
return optionalSteamId;
}
/**
* Gets whether the client will register a run command with discord
*
* @return autoRegister
*/
public boolean isAutoRegister() {
return autoRegister;
}
/**
* Gets encoding to send packets in.
* Default: UTF-8
*
* @return encoding
*/
public String getEncoding() {
return this.encoding;
}
/**
* Sets the encoding to send packets in.
*
* This can be set safely before a call to {@link #connect(DiscordBuild...)}
* is made.
*
* Default: UTF-8
*
* @param encoding for this IPCClient.
*/
public void setEncoding(final String encoding) {
this.encoding = encoding;
}
/**
* Gets the client ID associated with this IPCClient
*
* @return the client id
*/
public long getClientID() {
return this.clientId;
}
/**
* Gets whether this IPCClient is in Debug Mode
* Default: False
*
* @return The Debug Mode Status
*/
public boolean isDebugMode() {
return debugMode;
}
/**
* Sets whether this IPCClient is in Debug Mode
*
* @param debugMode The Debug Mode Status
*/
public void setDebugMode(boolean debugMode) {
this.debugMode = debugMode;
}
/**
* Gets whether this IPCClient will show verbose logging
* Default: False
*
* @return The Verbose Logging Status
*/
public boolean isVerboseLogging() {
return verboseLogging;
}
/**
* Sets whether this IPCClient will show verbose logging
*
* @param verboseLogging The Verbose Mode Status
*/
public void setVerboseLogging(boolean verboseLogging) {
this.verboseLogging = verboseLogging;
}
/**
* Opens the connection between the IPCClient and Discord.
*
* This must be called before any data is exchanged between the
* IPCClient and Discord.
*
* @param preferredOrder the priority order of client builds to connect to
* @throws IllegalStateException There is an open connection on this IPCClient.
* @throws NoDiscordClientException No client of the provided {@link DiscordBuild build type}(s) was found.
*/
@SuppressWarnings("BusyWait")
public void connect(DiscordBuild... preferredOrder) throws NoDiscordClientException {
checkConnected(false);
long timeToConnect;
while ((timeToConnect = nextDelay - System.currentTimeMillis()) > 0) {
if (debugMode) {
getCurrentLogger(LOGGER).info("[DEBUG] Attempting connection in: " + timeToConnect + "ms");
}
try {
Thread.sleep(timeToConnect);
} catch (InterruptedException ignored) {
}
}
callbacks.clear();
pipe = null;
try {
pipe = Pipe.openPipe(this, clientId, callbacks, preferredOrder);
} catch (Exception ex) {
updateReconnectTime();
throw ex;
}
if (isAutoRegister()) {
try {
if (optionalSteamId != null && !optionalSteamId.isEmpty())
this.registerSteamGame(getApplicationId(), optionalSteamId);
else
this.registerApp(getApplicationId(), null);
} catch (Throwable ex) {
if (debugMode) {
getCurrentLogger(LOGGER).error("Unable to register application", ex);
} else {
getCurrentLogger(LOGGER).error("Unable to register application, enable debug mode for trace...");
}
}
}
if (debugMode) {
getCurrentLogger(LOGGER).info("[DEBUG] Client is now connected and ready!");
}
if (listener != null) {
listener.onReady(this);
pipe.setListener(listener);
}
startReading();
}
/**
* Sends a {@link RichPresence} to the Discord client.
*
* This is where the IPCClient will officially display
* a Rich Presence in the Discord client.
*
* Sending this again will overwrite the last provided
* {@link RichPresence}.
*
* @param presence The {@link RichPresence} to send.
* @throws IllegalStateException If a connection was not made prior to invoking
* this method.
* @see RichPresence
*/
public void sendRichPresence(RichPresence presence) {
sendRichPresence(presence, null);
}
/**
* Sends a {@link RichPresence} to the Discord client.
*
* This is where the IPCClient will officially display
* a Rich Presence in the Discord client.
*
* Sending this again will overwrite the last provided
* {@link RichPresence}.
*
* @param presence The {@link RichPresence} to send.
* @param callback A {@link Callback} to handle success or error
* @throws IllegalStateException If a connection was not made prior to invoking
* this method.
* @see RichPresence
*/
public void sendRichPresence(RichPresence presence, Callback callback) {
checkConnected(true);
if (debugMode) {
getCurrentLogger(LOGGER).info("[DEBUG] Sending RichPresence to discord: " + presence.toDecodedJson(encoding));
}
// Setup and Send JsonObject Data Representing an RPC Update
JsonObject finalObject = new JsonObject(),
args = new JsonObject();
finalObject.addProperty("cmd", "SET_ACTIVITY");
args.addProperty("pid", getPID());
args.add("activity", presence.toJson());
finalObject.add("args", args);
pipe.send(OpCode.FRAME, finalObject, callback);
}
/**
* Manually register a steam game
*
* @param applicationId Application ID
* @param optionalSteamId Application Steam ID
*/
public void registerSteamGame(String applicationId, String optionalSteamId) {
if (this.pipe != null)
this.pipe.registerSteamGame(applicationId, optionalSteamId);
}
/**
* Manually register an application
*
* @param applicationId Application ID
* @param command Command to run the application
*/
public void registerApp(String applicationId, String command) {
if (this.pipe != null)
this.pipe.registerApp(applicationId, command);
}
/**
* Adds an event {@link Event} to this IPCClient.
* If the provided {@link Event} is added more than once,
* it does nothing.
* Once added, there is no way to remove the subscription
* other than {@link #close() closing} the connection
* and creating a new one.
*
* @param sub The event {@link Event} to add.
* @throws IllegalStateException If a connection was not made prior to invoking
* this method.
*/
public void subscribe(Event sub) {
subscribe(sub, null);
}
/**
* Adds an event {@link Event} to this IPCClient.
* If the provided {@link Event} is added more than once,
* it does nothing.
* Once added, there is no way to remove the subscription
* other than {@link #close() closing} the connection
* and creating a new one.
*
* @param sub The event {@link Event} to add.
* @param callback The {@link Callback} to handle success or failure
* @throws IllegalStateException If a connection was not made prior to invoking
* this method.
*/
public void subscribe(Event sub, Callback callback) {
checkConnected(true);
if (!sub.isSubscribable())
throw new IllegalStateException("Cannot subscribe to " + sub + " event!");
if (debugMode) {
getCurrentLogger(LOGGER).info(String.format("[DEBUG] Subscribing to Event: %s", sub.name()));
}
JsonObject pipeData = new JsonObject();
pipeData.addProperty("cmd", "SUBSCRIBE");
pipeData.addProperty("evt", sub.name());
pipe.send(OpCode.FRAME, pipeData, callback);
}
/**
* Responds to a {@link Event#ACTIVITY_JOIN_REQUEST} from a requester {@link User}.
*
* @param user The {@link User} to respond to
* @param approvalMode The {@link ApprovalMode} to respond to the requester with
* @param callback The {@link Callback} to handle success or failure
*/
public void respondToJoinRequest(User user, ApprovalMode approvalMode, Callback callback) {
checkConnected(true);
if (user != null) {
if (debugMode) {
getCurrentLogger(LOGGER).info(String.format("[DEBUG] Sending response to %s as %s", user.getName(), approvalMode.name()));
}
JsonObject pipeData = new JsonObject();
pipeData.addProperty("cmd", approvalMode == ApprovalMode.ACCEPT ? "SEND_ACTIVITY_JOIN_INVITE" : "CLOSE_ACTIVITY_JOIN_REQUEST");
JsonObject args = new JsonObject();
args.addProperty("user_id", user.getId());
pipeData.add("args", args);
pipe.send(OpCode.FRAME, pipeData, callback);
}
}
/**
* Responds to a {@link Event#ACTIVITY_JOIN_REQUEST} from a requester {@link User}.
*
* @param user The {@link User} to respond to
* @param approvalMode The {@link ApprovalMode} to respond to the requester with
*/
public void respondToJoinRequest(User user, ApprovalMode approvalMode) {
respondToJoinRequest(user, approvalMode, null);
}
/**
* Gets the IPCClient's current {@link PipeStatus}.
*
* @return The IPCClient's current {@link PipeStatus}.
*/
public PipeStatus getStatus() {
if (pipe == null) return PipeStatus.UNINITIALIZED;
return pipe.getStatus();
}
/**
* Attempts to close an open connection to Discord.
* This can be reopened with another call to {@link #connect(DiscordBuild...)}.
*
* @throws IllegalStateException If a connection was not made prior to invoking
* this method.
*/
@Override
public void close() {
checkConnected(true);
try {
pipe.close();
} catch (IOException e) {
if (debugMode) {
getCurrentLogger(LOGGER).info(String.format("[DEBUG] Failed to close pipe: %s", e));
}
}
}
/**
* Gets the IPCClient's {@link DiscordBuild}.
*
* This is always the first specified DiscordBuild when
* making a call to {@link #connect(DiscordBuild...)},
* or the first one found if none or {@link DiscordBuild#ANY}
* is specified.
*
* Note that specifying ANY doesn't mean that this will return
* ANY. In fact this method should never return the
* value ANY.
*
* @return The {@link DiscordBuild} of this IPCClient, or null if not connected.
*/
public DiscordBuild getDiscordBuild() {
if (pipe == null) return null;
return pipe.getDiscordBuild();
}
/**
* Gets the IPCClient's current {@link User} attached to the target {@link DiscordBuild}.
*
* This is always the User Data attached to the DiscordBuild found when
* making a call to {@link #connect(DiscordBuild...)}
*
* Note that this value should NOT return null under any circumstances.
*
* @return The current {@link User} of this IPCClient from the target {@link DiscordBuild}, or null if not found.
*/
public User getCurrentUser() {
if (pipe == null) return null;
return pipe.getCurrentUser();
}
// Private methods
/**
* Makes sure that the client is connected (or not) depending on if it should
* for the current state.
*
* @param connected Whether to check in the context of the IPCClient being
* connected or not.
*/
private void checkConnected(boolean connected) {
if (connected && getStatus() != PipeStatus.CONNECTED)
throw new IllegalStateException(String.format("IPCClient (ID: %d) is not connected!", clientId));
if (!connected && getStatus() == PipeStatus.CONNECTED)
throw new IllegalStateException(String.format("IPCClient (ID: %d) is already connected!", clientId));
}
/**
* Initializes this IPCClient's {@link IPCClient#readThread readThread}
* and calls the first {@link Pipe#read()}.
*/
private void startReading() {
final IPCClient localInstance = this;
readThread = new Thread(() -> IPCClient.this.readPipe(localInstance), "IPCClient-Reader");
readThread.setDaemon(true);
if (debugMode) {
getCurrentLogger(LOGGER).info("[DEBUG] Starting IPCClient reading thread!");
}
readThread.start();
}
/**
* Call the first {@link Pipe#read()} via try-catch
*
* @param instance The {@link IPCClient} instance
*/
private void readPipe(final IPCClient instance) {
try {
Packet p;
while ((p = pipe.read()).getOp() != OpCode.CLOSE) {
JsonObject json = p.getJson();
if (json != null) {
Event event = Event.of(json.has("evt") && !json.get("evt").isJsonNull() ? json.getAsJsonPrimitive("evt").getAsString() : null);
String nonce = json.has("nonce") && !json.get("nonce").isJsonNull() ? json.getAsJsonPrimitive("nonce").getAsString() : null;
switch (event) {
case NULL:
if (nonce != null && callbacks.containsKey(nonce))
callbacks.remove(nonce).succeed(p);
break;
case ERROR:
if (nonce != null && callbacks.containsKey(nonce))
callbacks.remove(nonce).fail(json.has("data") && json.getAsJsonObject("data").has("message") ? json.getAsJsonObject("data").getAsJsonObject("message").getAsString() : null);
break;
case ACTIVITY_JOIN:
if (debugMode) {
getCurrentLogger(LOGGER).info("[DEBUG] Reading thread received a 'join' event.");
}
break;
case ACTIVITY_SPECTATE:
if (debugMode) {
getCurrentLogger(LOGGER).info("[DEBUG] Reading thread received a 'spectate' event.");
}
break;
case ACTIVITY_JOIN_REQUEST:
if (debugMode) {
getCurrentLogger(LOGGER).info("[DEBUG] Reading thread received a 'join request' event.");
}
break;
case UNKNOWN:
if (debugMode) {
getCurrentLogger(LOGGER).info("[DEBUG] Reading thread encountered an event with an unknown type: " +
json.getAsJsonPrimitive("evt").getAsString());
}
break;
default:
break;
}
if (listener != null && json.has("cmd") && json.getAsJsonPrimitive("cmd").getAsString().equals("DISPATCH")) {
try {
JsonObject data = json.getAsJsonObject("data");
switch (Event.of(json.getAsJsonPrimitive("evt").getAsString())) {
case ACTIVITY_JOIN:
listener.onActivityJoin(instance, data.getAsJsonPrimitive("secret").getAsString());
break;
case ACTIVITY_SPECTATE:
listener.onActivitySpectate(instance, data.getAsJsonPrimitive("secret").getAsString());
break;
case ACTIVITY_JOIN_REQUEST:
final JsonObject u = data.getAsJsonObject("user");
final User user = new User(
u.getAsJsonPrimitive("username").getAsString(),
u.has("global_name") && u.get("global_name").isJsonPrimitive() ? u.getAsJsonPrimitive("global_name").getAsString() : null,
u.has("discriminator") && u.get("discriminator").isJsonPrimitive() ? u.getAsJsonPrimitive("discriminator").getAsString() : "0",
Long.parseLong(u.getAsJsonPrimitive("id").getAsString()),
u.has("avatar") && u.get("avatar").isJsonPrimitive() ? u.getAsJsonPrimitive("avatar").getAsString() : null
);
listener.onActivityJoinRequest(instance, data.has("secret") ? data.getAsJsonObject("secret").getAsString() : null, user);
break;
default:
break;
}
} catch (Exception e) {
getCurrentLogger(LOGGER).error(String.format("Exception when handling event: %s", e));
}
}
}
}
pipe.setStatus(PipeStatus.DISCONNECTED);
if (listener != null)
listener.onClose(instance, p.getJson());
} catch (IOException | JsonParseException ex) {
getCurrentLogger(LOGGER).error(String.format("Reading thread encountered an Exception: %s", ex));
pipe.setStatus(PipeStatus.DISCONNECTED);
if (listener != null) {
RECONNECT_TIME_MS.reset();
updateReconnectTime();
listener.onDisconnect(instance, ex);
}
}
}
/**
* Sets the next delay before re-attempting connection.
*/
private void updateReconnectTime() {
nextDelay = System.currentTimeMillis() + RECONNECT_TIME_MS.nextDelay();
}
/**
* Constants representing a Response to an Ask to Join or Spectate Request
*/
public enum ApprovalMode {
ACCEPT, DENY
}
/**
* Constants representing events that can be subscribed to
* using {@link #subscribe(Event)}.
*
* Each event corresponds to a different function as a
* component of the Rich Presence.
* A full breakdown of each is available
* here.
*/
public enum Event {
NULL(false), // used for confirmation
READY(false),
ERROR(false),
ACTIVITY_JOIN(true),
ACTIVITY_SPECTATE(true),
ACTIVITY_JOIN_REQUEST(true),
/**
* A backup key, only important if the
* IPCClient receives an unknown event
* type in a JSON payload.
*/
UNKNOWN(false);
private final boolean subscribable;
Event(boolean subscribable) {
this.subscribable = subscribable;
}
static Event of(String str) {
if (str == null)
return NULL;
for (Event s : Event.values()) {
if (s != UNKNOWN && s.name().equalsIgnoreCase(str))
return s;
}
return UNKNOWN;
}
public boolean isSubscribable() {
return subscribable;
}
}
}