eu.m6r.kicker.slack.Bot.java Source code

Java tutorial

Introduction

Here is the source code for eu.m6r.kicker.slack.Bot.java

Source

/*
 * This file is part of kicker (https://github.com/mbrtargeting/kicker).
 * Copyright (c) 2019 Jan Graegger.
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, version 3.
 *
 * This program 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
 * General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program. If not, see <http://www.gnu.org/licenses/>.
 */

package eu.m6r.kicker.slack;

import java.io.IOException;
import java.net.URI;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.regex.Pattern;
import javax.websocket.ClientEndpoint;
import javax.websocket.CloseReason;
import javax.websocket.ContainerProvider;
import javax.websocket.DeploymentException;
import javax.websocket.OnClose;
import javax.websocket.OnError;
import javax.websocket.OnMessage;
import javax.websocket.Session;
import javax.ws.rs.client.Client;
import javax.ws.rs.client.ClientBuilder;
import javax.ws.rs.client.ResponseProcessingException;
import javax.ws.rs.core.MediaType;

import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.apache.zookeeper.KeeperException;
import org.apache.zookeeper.WatchedEvent;
import org.apache.zookeeper.Watcher;
import org.glassfish.jersey.client.ClientProperties;

import eu.m6r.kicker.Controller;
import eu.m6r.kicker.models.Player;
import eu.m6r.kicker.models.PlayerQueue;
import eu.m6r.kicker.slack.models.ChannelJoined;
import eu.m6r.kicker.slack.models.Message;
import eu.m6r.kicker.slack.models.RtmInitResponse;
import eu.m6r.kicker.slack.models.SlackUser;
import eu.m6r.kicker.utils.JsonConverter;
import eu.m6r.kicker.utils.ZookeeperClient;

@ClientEndpoint
public class Bot implements Watcher {

    private static final Pattern COMMAND_PATTERN = Pattern.compile("\\w+");
    private static final Pattern USER_PATTERN = Pattern.compile("<@([^>]*)>");
    private static final String ZOO_KEEPER_PARENT_PATH = ZookeeperClient.ZOOKEEPER_ROOT_PATH + "/bot";
    private static final String ZOO_KEEPER_PATH = ZOO_KEEPER_PARENT_PATH + "/slack_";
    private static final String BOT_ID = UUID.randomUUID().toString();

    private final Logger logger;
    private final String token;
    private final JsonConverter jsonConverter;
    private final Controller controller;
    private final Client client;
    private final ZookeeperClient zookeeperClient;
    private final String lockNode;
    private final MessageWriter messageWriter;

    private Session socketSession;
    private String botUserId;
    private Pattern botUserIdPattern;

    public Bot(final String token, final ZookeeperClient zookeeperClient)
            throws KeeperException, InterruptedException, StartSocketSessionException, IOException {
        this.logger = LogManager.getLogger();

        if (token == null) {
            logger.error("Slack token is not set.");
            throw new RuntimeException("Slack token cannot be null");
        }

        this.client = ClientBuilder.newClient();
        client.property(ClientProperties.CONNECT_TIMEOUT, 30000);
        client.property(ClientProperties.READ_TIMEOUT, 30000);

        this.token = token;
        this.controller = Controller.getInstance();
        this.zookeeperClient = zookeeperClient;
        this.messageWriter = new MessageWriter(token);

        this.jsonConverter = new JsonConverter(Message.class, ChannelJoined.class, SlackUser.class);

        zookeeperClient.createPath(ZOO_KEEPER_PARENT_PATH);
        this.lockNode = zookeeperClient.createEphemeralSequential(ZOO_KEEPER_PATH, BOT_ID);

        logger.info("Creating lock {} for ID {}", lockNode, BOT_ID);

        if (zookeeperClient.checkLock(lockNode, this)) {
            logger.info("Obtained lock {} and starting new slack session.", lockNode);
            startNewSession();
        }
    }

    private void startNewSession() throws StartSocketSessionException {
        final var target = client.target("https://slack.com").path("/api/rtm.connect").queryParam("token", token);

        final var response = target.request(MediaType.APPLICATION_JSON).get(RtmInitResponse.class);

        if (response == null) {
            throw new StartSocketSessionException("Failed to parse response object");
        }

        if (!response.ok) {
            throw new StartSocketSessionException(response.error);
        }

        if (response.warning != null) {
            logger.warn(response.warning);
        }

        final var botMention = String.format("<@%s>", response.self.id);
        this.botUserIdPattern = Pattern.compile(botMention);
        this.botUserId = response.self.id;

        final var socketClient = ContainerProvider.getWebSocketContainer();
        try {
            logger.info("Starting new web socket session with {}", response.url);
            this.socketSession = socketClient.connectToServer(this, URI.create(response.url));
        } catch (final DeploymentException | IOException e) {
            throw new StartSocketSessionException(e);
        }
    }

    @Override
    public void process(WatchedEvent event) {
        logger.info("Zookeeper event: {}", event);

        try {
            if (this.socketSession == null && zookeeperClient.checkLock(lockNode, this)) {
                logger.info("Obtained lock {} and starting new slack session.", lockNode);
                startNewSession();
            }
        } catch (KeeperException | InterruptedException | StartSocketSessionException e) {
            logger.error("Failed to process zookeeper event.", e);
        }
    }

    @OnMessage
    public void onMessage(final String messageString, final Session session) throws IOException {
        logger.info(messageString);

        if (messageString.contains("\"type\":\"channel_joined\"")) {

            final var channelJoined = jsonConverter.fromString(messageString, ChannelJoined.class);
            final var id = controller.joinChannel(channelJoined.channel.id, channelJoined.channel.name);
            sendChannelJoinedMessage(channelJoined.channel.id, id);

        } else if (messageString.contains("\"type\":\"message\"")) {
            final var message = jsonConverter.fromString(messageString, Message.class);

            if (message.text == null) {
                return;
            }
            final var matcher = botUserIdPattern.matcher(message.text);

            if (message.type != null && message.type.equals("message") && matcher.find()) {
                final var command = message.text.substring(matcher.end()).trim();
                onCommand(command, message.channel, message.user);
            }
        }
    }

    @OnClose
    public void onClose(final Session session, final CloseReason closeReason) {
        logger.error("Session closed: {}", closeReason);
        System.exit(1);
    }

    @OnError
    public void onError(final Session session, final Throwable throwable) {
        logger.error("Websocket error.", throwable);
        System.exit(1);
    }

    private void onCommand(final String command, final String slackChannelId, final String sender)
            throws IOException {
        final var commandMatcher = COMMAND_PATTERN.matcher(command);
        if (commandMatcher.find()) {
            final var action = commandMatcher.group();
            final var userMatcher = USER_PATTERN.matcher(command);
            final var channelId = controller.getChannelId(slackChannelId);

            List<String> userIds = new ArrayList<>();

            while (userMatcher.find()) {
                userIds.add(userMatcher.group(1));
            }

            if (userIds.isEmpty()) {
                userIds.add(sender);
            }

            try {
                switch (action) {
                case "add":
                case "play":
                    for (final String userId : userIds) {
                        try {
                            final var player = getUser(userId);
                            controller.addPlayer(channelId, player);
                        } catch (PlayerQueue.PlayerAlreadyInQueueException e) {
                            sendMessage(e.getMessage(), slackChannelId);
                        } catch (final PlayerQueue.TooManyUsersException e) {
                            sendMessage(e.getMessage(), slackChannelId);
                            break;
                        }
                    }

                    final var playersInQueue = controller.getPlayersString(channelId);
                    if (!playersInQueue.isEmpty()) {
                        sendMessage(String.format("Current queue: %s", playersInQueue), slackChannelId);
                    }
                    break;
                case "reset":
                    controller.resetPlayers(channelId);
                    sendMessage("Cleared queue.", slackChannelId);
                    break;
                case "remove":
                    for (final var userId : userIds) {
                        final var playerToRemove = getUser(userId);
                        controller.removePlayer(channelId, playerToRemove);
                        sendMessage(String.format("Removed <@%s> from the queue", playerToRemove.id),
                                slackChannelId);
                    }
                    break;
                case "queue":
                    final String queueMessage;
                    if (controller.getPlayersInQueue(channelId).isEmpty()) {
                        queueMessage = "Queue is empty!";
                    } else {
                        queueMessage = "Current queue: " + controller.getPlayersString(channelId);
                    }

                    sendMessage(queueMessage, slackChannelId);
                    break;
                case "cancel":
                    if (controller.cancelRunningTournament(channelId)) {
                        sendMessage("Canceled the running match!", slackChannelId);
                    } else {
                        sendMessage("No match running!", slackChannelId);
                    }
                    break;
                case "fixedMatch":
                    if (userIds.size() != 4) {
                        sendMessage("To start a game I need 4 players :(", slackChannelId);
                    } else {
                        try {
                            controller.resetPlayers(channelId);
                            for (final var userId : userIds) {
                                final var player = getUser(userId);
                                controller.addPlayer(channelId, player);
                            }

                            controller.startTournament(channelId, false, 3);
                        } catch (PlayerQueue.PlayerAlreadyInQueueException
                                | Controller.TournamentRunningException e) {
                            sendMessage(e.getMessage(), slackChannelId);
                        }

                    }
                    break;
                case "url":
                    sendChannelUrlMessage(channelId, slackChannelId, sender);
                    break;
                case "help":
                    sendHelpMessage(slackChannelId, sender);
                    break;
                default:
                    sendMessage(String.format(
                            "I'm sorry <@%s>, I didn't understand that. " + "If you need help just ask for it.",
                            sender), slackChannelId);
                }
            } catch (final PlayerQueue.TooManyUsersException | UserExtractionFailedException e) {
                sendMessage(e.getMessage(), sender);
            }
        } else {
            sendMessage("That doesn't make any sense at all.", slackChannelId);
        }
    }

    private void sendHelpMessage(final String channel, final String sender) {
        final var text = "Supported slack commands:";
        final var message = new Message(channel, text, sender);

        final var addCommand = new Message.Attachment("add", "_Adds new player(s) to the queue._");
        final List<Message.Attachment.Field> addFields = new ArrayList<>();
        addFields.add(new Message.Attachment.Field("Add yourself", String.format("<@%s> add", botUserId)));
        addFields.add(new Message.Attachment.Field("Add others",
                String.format("<@%s> add <@U12G6EUSZ> <@U12RUGB7E>", botUserId)));
        addCommand.fields = addFields;

        message.attachments.add(addCommand);

        final var removeCommand = new Message.Attachment("remove", "_Removes player(s) from the queue._");
        final List<Message.Attachment.Field> removeFields = new ArrayList<>();
        removeFields.add(new Message.Attachment.Field("Remove yourself", String.format("<@%s> remove", botUserId)));
        removeFields.add(new Message.Attachment.Field("Remove others",
                String.format("<@%s> remove <@U12GTAA49> <@U5GEP6RMM>", botUserId)));
        removeCommand.fields = removeFields;

        message.attachments.add(removeCommand);

        final var queueCommand = new Message.Attachment("queue", "_Shows the current queue._");
        final List<Message.Attachment.Field> queueFields = new ArrayList<>();
        queueFields.add(new Message.Attachment.Field(String.format("<@%s> queue", botUserId)));
        queueCommand.fields = queueFields;

        message.attachments.add(queueCommand);

        final var fixedMatchCommand = new Message.Attachment("fixedMatch",
                "_Creates a new match. Keeps the order of the players. "
                        + "First and last two players will play together._");
        final List<Message.Attachment.Field> fixedMatchFields = new ArrayList<>();
        fixedMatchFields.add(new Message.Attachment.Field(String
                .format("<@%s> fixedMatch <@U6WRKPL6P> <@U12G6EUSZ> <@U3ZCMB9SR> " + "<@U2D3PT6JK>", botUserId)));
        fixedMatchCommand.fields = fixedMatchFields;

        message.attachments.add(fixedMatchCommand);

        final var resetCommand = new Message.Attachment("reset", "_Reset the queue._");
        final List<Message.Attachment.Field> resetFields = new ArrayList<>();
        resetFields.add(new Message.Attachment.Field(String.format("<@%s> reset", botUserId)));
        resetCommand.fields = resetFields;

        message.attachments.add(resetCommand);

        final var cancelCommand = new Message.Attachment("cancel", "_Cancel a running match._");
        final List<Message.Attachment.Field> cancelFields = new ArrayList<>();
        cancelFields.add(new Message.Attachment.Field(String.format("<@%s> cancel", botUserId)));
        cancelCommand.fields = cancelFields;

        message.attachments.add(cancelCommand);
        messageWriter.postEphemeral(message);
    }

    private Message.Attachment getQRCodeAttachment(final String channelId) {
        final var attachment = new Message.Attachment("Your Channel QR-Code",
                "You can scan this code from on " + controller.getBaseUrl());
        attachment.imageUrl = controller.getChannelQRCodeUrl(channelId);
        return attachment;
    }

    private void sendMessage(final String text, final String channel) {
        final var message = new Message(channel, text, botUserId);
        sendMessage(message);
    }

    private void sendMessage(final Message message) {
        try {
            socketSession.getAsyncRemote().sendText(jsonConverter.toString(message));
        } catch (final IOException e) {
            logger.error("Failed to process message json.", e);
        }
    }

    private Player getUser(final String userId) throws UserExtractionFailedException {
        final var target = client.target("https://slack.com").path("/api/users.info").queryParam("token", token)
                .queryParam("user", userId);
        try {
            final var userString = target.request(MediaType.APPLICATION_JSON).get(String.class);
            final var slackUser = jsonConverter.fromString(userString, SlackUser.class);

            final var player = new Player();
            player.id = slackUser.user.id;
            player.name = slackUser.user.name;
            player.avatarImage = slackUser.user.profile.image192;
            return player;
        } catch (final ResponseProcessingException | IOException e) {
            throw new UserExtractionFailedException(userId, e);
        }
    }

    private void sendChannelJoinedMessage(final String channel, final String id) {
        final var url = controller.getChannelUrl(id);
        final var messageText = String.format("Nice to meet you! I'm your new favourite kicker-bot. Go to %s to "
                + "find your team stats and to enter your results.", url);
        final var message = new Message(channel, messageText, botUserId);
        message.attachments.add(getQRCodeAttachment(channel));
        messageWriter.postMessage(message);
    }

    private void sendChannelUrlMessage(final String channel, final String id, final String userId) {
        final var url = controller.getChannelUrl(channel);
        final var message = new Message(id, url, userId);
        message.attachments.add(getQRCodeAttachment(channel));
        messageWriter.postEphemeral(message);
    }

    public static class StartSocketSessionException extends Exception {

        StartSocketSessionException(final Throwable cause) {
            super("Failed to establish web socket connection.", cause);
        }

        StartSocketSessionException(final String error) {
            super(String.format("Failed to establish web socket connection. Cause: %s", error));
        }
    }

    public static class UserExtractionFailedException extends Exception {

        UserExtractionFailedException(final String userId, final Throwable cause) {
            super(String.format("Failed to get user informations for '%s' from slack API.", userId), cause);
        }
    }
}