de.btobastian.javacord.utils.DiscordWebsocketAdapter.java Source code

Java tutorial

Introduction

Here is the source code for de.btobastian.javacord.utils.DiscordWebsocketAdapter.java

Source

/*
 * Copyright (C) 2017 Bastian Oppermann
 *
 * This file is part of Javacord.
 *
 * Javacord is free software; you can redistribute it and/or modify
 * it under the terms of the GNU Lesser general Public License as
 * published by the Free Software Foundation; either version 3 of
 * the License, or (at your option) any later version.
 *
 * Javacord is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 * GNU Lesser General Public License for more details.
 *
 * You should have received a copy of the GNU Lesser General Public
 * License along with this program; if not, see <http://www.gnu.org/licenses/>.
 */
package de.btobastian.javacord.utils;

import com.google.common.util.concurrent.SettableFuture;
import com.neovisionaries.ws.client.*;
import de.btobastian.javacord.ImplDiscordAPI;
import de.btobastian.javacord.utils.handler.ReadyHandler;
import de.btobastian.javacord.utils.handler.ResumedHandler;
import de.btobastian.javacord.utils.handler.channel.ChannelCreateHandler;
import de.btobastian.javacord.utils.handler.channel.ChannelDeleteHandler;
import de.btobastian.javacord.utils.handler.channel.ChannelUpdateHandler;
import de.btobastian.javacord.utils.handler.message.*;
import de.btobastian.javacord.utils.handler.server.*;
import de.btobastian.javacord.utils.handler.server.role.GuildRoleCreateHandler;
import de.btobastian.javacord.utils.handler.server.role.GuildRoleDeleteHandler;
import de.btobastian.javacord.utils.handler.server.role.GuildRoleUpdateHandler;
import de.btobastian.javacord.utils.handler.user.PresenceUpdateHandler;
import de.btobastian.javacord.utils.handler.user.UserGuildSettingsUpdateHandler;
import de.btobastian.javacord.utils.handler.voice.VoiceStateUpdateHandler;
import org.json.JSONObject;
import org.slf4j.Logger;

import javax.net.ssl.SSLContext;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.security.NoSuchAlgorithmException;
import java.util.*;
import java.util.concurrent.Future;
import java.util.zip.DataFormatException;
import java.util.zip.Inflater;

/**
 * The main websocket adapter.
 */
public class DiscordWebsocketAdapter extends WebSocketAdapter {

    /**
     * The logger of this class.
     */
    private static final Logger logger = LoggerUtil.getLogger(DiscordWebsocketAdapter.class);

    private final ImplDiscordAPI api;
    private final HashMap<String, PacketHandler> handlers = new HashMap<>();
    private final SettableFuture<Boolean> ready = SettableFuture.create();
    private final String gateway;

    private WebSocket websocket = null;

    private Timer heartbeatTimer = null;

    private int heartbeatInterval = -1;
    private int lastSeq = -1;
    private String sessionId = null;

    private boolean heartbeatAckReceived = false;

    public DiscordWebsocketAdapter(ImplDiscordAPI api, String gateway) {
        this.api = api;
        this.gateway = gateway;

        registerHandlers();

        connect();
    }

    private void connect() {
        WebSocketFactory factory = new WebSocketFactory();
        try {
            factory.setSSLContext(SSLContext.getDefault());
        } catch (NoSuchAlgorithmException e) {
            logger.warn("An error occurred while setting ssl context", e);
        }
        try {
            websocket = factory.createSocket(gateway + "?encoding=json&v=5");
            websocket.addHeader("Accept-Encoding", "gzip");
            websocket.addListener(this);
            websocket.connect();
        } catch (IOException | WebSocketException e) {
            logger.warn("An error occurred while connecting to websocket", e);
        }
    }

    @Override
    public void onConnected(WebSocket websocket, Map<String, List<String>> headers) throws Exception {
        if (sessionId == null) {
            sendIdentify(websocket);
        } else {
            sendResume(websocket);
        }
    }

    @Override
    public void onDisconnected(WebSocket websocket, WebSocketFrame serverCloseFrame,
            WebSocketFrame clientCloseFrame, boolean closedByServer) throws Exception {
        if (closedByServer) {
            logger.info("Websocket closed with reason {} and code {} by server!",
                    serverCloseFrame != null ? serverCloseFrame.getCloseReason() : "unknown",
                    serverCloseFrame != null ? serverCloseFrame.getCloseCode() : "unknown");
        } else {
            switch (clientCloseFrame == null ? -1 : clientCloseFrame.getCloseCode()) {
            case 1002:
            case 1008:
                logger.debug("Websocket closed! Trying to resume connection.");
                break;
            default:
                logger.info("Websocket closed with reason {} and code {} by client!",
                        clientCloseFrame != null ? clientCloseFrame.getCloseReason() : "unknown",
                        clientCloseFrame != null ? clientCloseFrame.getCloseCode() : "unknown");
                break;
            }
        }

        if (!ready.isDone()) {
            ready.set(false);
            return;
        }
        // Reconnect
        if (heartbeatTimer != null) {
            heartbeatTimer.cancel();
            heartbeatTimer = null;
        }

        connect();
    }

    @Override
    public void onTextMessage(WebSocket websocket, String text) throws Exception {
        JSONObject packet = new JSONObject(text);

        int op = packet.getInt("op");

        switch (op) {
        case 0:
            lastSeq = packet.getInt("s");
            String type = packet.getString("t");
            PacketHandler handler = handlers.get(type);
            if (handler != null) {
                handler.handlePacket(packet.getJSONObject("d"));
            } else {
                logger.debug("Received unknown packet of type {} (packet: {})", type, packet.toString());
            }

            if (type.equals("RESUMED")) {
                // We are the one who send the first heartbeat
                heartbeatAckReceived = true;
                heartbeatTimer = startHeartbeat(websocket, heartbeatInterval);
                logger.debug("Received RESUMED packet");
            }
            if (type.equals("READY") && sessionId == null) {
                // We are the one who send the first heartbeat
                heartbeatAckReceived = true;
                heartbeatTimer = startHeartbeat(websocket, heartbeatInterval);
                sessionId = packet.getJSONObject("d").getString("session_id");
                if (api.isWaitingForServersOnStartup()) {
                    // Discord sends us GUILD_CREATE packets after logging in. We will wait for them.
                    api.getThreadPool().getExecutorService().submit(new Runnable() {
                        @Override
                        public void run() {
                            int amount = api.getServers().size();
                            for (;;) {
                                try {
                                    Thread.sleep(2000);
                                } catch (InterruptedException ignored) {
                                }
                                if (api.getServers().size() <= amount) {
                                    break; // two seconds without new servers becoming available
                                }
                                amount = api.getServers().size();
                            }
                            ready.set(true);
                        }
                    });
                } else {
                    ready.set(true);
                }
                logger.debug("Received READY packet");
            } else if (type.equals("READY")) {
                heartbeatAckReceived = true;
                heartbeatTimer = startHeartbeat(websocket, heartbeatInterval);
            }
            break;
        case 1:
            sendHeartbeat(websocket);
            break;
        case 7:
            logger.debug("Received op 7 packet. Reconnecting...");
            websocket.sendClose(1000);
            connect();
            break;
        case 9:
            // Invalid session :(
            logger.info("Could not resume session. Reconnecting now...");
            sendIdentify(websocket);
            break;
        case 10:
            JSONObject data = packet.getJSONObject("d");
            heartbeatInterval = data.getInt("heartbeat_interval");
            logger.debug("Received HELLO packet");
            break;
        case 11:
            heartbeatAckReceived = true;
            break;
        default:
            logger.debug("Received unknown packet (op: {}, content: {})", op, packet.toString());
            break;
        }
    }

    @Override
    public void onBinaryMessage(WebSocket websocket, byte[] binary) throws Exception {
        Inflater decompressor = new Inflater();
        decompressor.setInput(binary);
        ByteArrayOutputStream bos = new ByteArrayOutputStream(binary.length);
        byte[] buf = new byte[1024];
        while (!decompressor.finished()) {
            int count;
            try {
                count = decompressor.inflate(buf);
            } catch (DataFormatException e) {
                logger.warn("An error occurred while decompressing data", e);
                return;
            }
            bos.write(buf, 0, count);
        }
        try {
            bos.close();
        } catch (IOException ignored) {
        }
        byte[] decompressedData = bos.toByteArray();
        try {
            onTextMessage(websocket, new String(decompressedData, "UTF-8"));
        } catch (UnsupportedEncodingException e) {
            logger.warn("An error occurred while decompressing data", e);
        }
    }

    /**
     * Starts the heartbeat.
     *
     * @param websocket The websocket the heartbeat should be sent to.
     * @param heartbeatInterval The heartbeat interval.
     * @return The timer used for the heartbeat.
     */
    private Timer startHeartbeat(final WebSocket websocket, final int heartbeatInterval) {
        final Timer timer = new Timer(true);
        timer.scheduleAtFixedRate(new TimerTask() {
            @Override
            public void run() {
                /* if (heartbeatAckReceived) { temporally removed */
                heartbeatAckReceived = false;
                sendHeartbeat(websocket);
                logger.debug("Sent heartbeat (interval: {})", heartbeatInterval);
                /*} else {
                logger.info("We did not receive an answer to our last heartbeat. Trying to reconnect!");
                websocket.sendClose(1002);
                }*/
            }
        }, 0, heartbeatInterval);
        return timer;
    }

    /**
     * Sends the heartbeat.
     *
     * @param websocket The websocket the heartbeat should be sent to.
     */
    private void sendHeartbeat(WebSocket websocket) {
        JSONObject heartbeatPacket = new JSONObject();
        heartbeatPacket.put("op", 1);
        heartbeatPacket.put("d", lastSeq);
        websocket.sendText(heartbeatPacket.toString());
    }

    /**
     * Sends the resume packet.
     *
     * @param websocket The websocket the resume packet should be sent to.
     */
    private void sendResume(WebSocket websocket) {
        JSONObject resumePacket = new JSONObject().put("op", 6).put("d",
                new JSONObject().put("token", api.getToken()).put("session_id", sessionId).put("seq", lastSeq));
        logger.debug("Sending resume packet");
        websocket.sendText(resumePacket.toString());
    }

    /**
     * Sends the identify packet.
     *
     * @param websocket The websocket the identify packet should be sent to.
     */
    private void sendIdentify(WebSocket websocket) {
        JSONObject identifyPacket = new JSONObject().put("op", 2).put("d",
                new JSONObject().put("token", api.getToken()).put("properties",
                        new JSONObject().put("$os", System.getProperty("os.name")).put("$browser", "Javacord")
                                .put("$device", "Javacord").put("$referrer", "").put("$referring_domain", ""))
                        .put("compress", true).put("large_threshold", 250));
        logger.debug("Sending identify packet");
        websocket.sendText(identifyPacket.toString());
    }

    /**
     * Registers all handlers.
     */
    private void registerHandlers() {
        // general
        addHandler(new ReadyHandler(api));
        addHandler(new ResumedHandler(api));

        // channel
        addHandler(new ChannelCreateHandler(api));
        addHandler(new ChannelDeleteHandler(api));
        addHandler(new ChannelUpdateHandler(api));

        // message
        addHandler(new MessageAckHandler(api));
        addHandler(new MessageBulkDeleteHandler(api));
        addHandler(new MessageCreateHandler(api));
        addHandler(new MessageDeleteHandler(api));
        addHandler(new MessageReactionAddHandler(api));
        addHandler(new MessageReactionRemoveHandler(api));
        addHandler(new MessageUpdateHandler(api));
        addHandler(new TypingStartHandler(api));

        // server
        addHandler(new GuildBanAddHandler(api));
        addHandler(new GuildBanRemoveHandler(api));
        addHandler(new GuildCreateHandler(api));
        addHandler(new GuildDeleteHandler(api));
        addHandler(new GuildMemberAddHandler(api));
        addHandler(new GuildMemberRemoveHandler(api));
        addHandler(new GuildMemberUpdateHandler(api));
        addHandler(new GuildUpdateHandler(api));

        // role
        addHandler(new GuildRoleCreateHandler(api));
        addHandler(new GuildRoleDeleteHandler(api));
        addHandler(new GuildRoleUpdateHandler(api));

        // user
        addHandler(new PresenceUpdateHandler(api));
        addHandler(new UserGuildSettingsUpdateHandler(api));

        // voice
        addHandler(new VoiceStateUpdateHandler(api));
    }

    /**
     * Adds a packet handler.
     *
     * @param handler The handler to add.
     */
    private void addHandler(PacketHandler handler) {
        handlers.put(handler.getType(), handler);
    }

    /**
     * Gets the websocket of the adapter.
     *
     * @return The websocket of the adapter.
     */
    public WebSocket getWebSocket() {
        return websocket;
    }

    /**
     * Gets the Future which tells whether the connection is ready or failed.
     *
     * @return The Future.
     */
    public Future<Boolean> isReady() {
        return ready;
    }

    /**
     * Sends the update status packet
     */
    public void updateStatus() {
        logger.debug("Updating status (game: {}, idle: {})", api.getGame() == null ? "none" : api.getGame(),
                api.isIdle());
        JSONObject game = new JSONObject();
        game.put("name", api.getGame() == null ? JSONObject.NULL : api.getGame());
        if (api.getStreamingUrl() != null) {
            game.put("url", api.getStreamingUrl()).put("type", 1);
        }
        JSONObject updateStatus = new JSONObject().put("op", 3).put("d",
                new JSONObject().put("game", game).put("idle_since", api.isIdle() ? 1 : JSONObject.NULL));
        websocket.sendText(updateStatus.toString());
    }

    /* === ERROR LOGGING === */

    @Override
    public void onError(WebSocket websocket, WebSocketException cause) throws Exception {
        switch (cause.getMessage()) {
        case "Flushing frames to the server failed: Connection closed by remote host":
        case "Flushing frames to the server failed: Socket is closed":
        case "Flushing frames to the server failed: Connection has been shutdown: javax.net.ssl.SSLException: java.net.SocketException: Connection reset":
        case "An I/O error occurred while a frame was being read from the web socket: Connection reset":
            break;
        default:
            logger.warn("Websocket error!", cause);
            break;
        }
    }

    @Override
    public void onUnexpectedError(WebSocket websocket, WebSocketException cause) throws Exception {
        logger.warn("Websocket onUnexpected error!", cause);
    }

    @Override
    public void onConnectError(WebSocket websocket, WebSocketException exception) throws Exception {
        logger.warn("Websocket onConnect error!", exception);
    }
}