com.omertron.slackbot.listeners.GoogleSheetsListener.java Source code

Java tutorial

Introduction

Here is the source code for com.omertron.slackbot.listeners.GoogleSheetsListener.java

Source

/*
 *      Copyright (c) 2017 Stuart Boston
 *
 *      This file is part of the BGG Slack Bot.
 *
 *      The BGG Slack Bot 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, either version 3 of the License, or
 *      any later version.
 *
 *      The BGG Slack Bot 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 the BGG Slack Bot.  If not, see <http://www.gnu.org/licenses/>.
 *
 */
package com.omertron.slackbot.listeners;

import com.google.api.services.sheets.v4.model.ValueRange;
import com.omertron.bgg.BggException;
import com.omertron.bgg.model.BoardGameExtended;
import com.omertron.bgg.model.CollectionItem;
import com.omertron.bgg.model.CollectionItemWrapper;
import com.omertron.slackbot.Constants;
import com.omertron.slackbot.SlackBot;
import com.omertron.slackbot.functions.GoogleSheets;
import static com.omertron.slackbot.listeners.AbstractListener.BGG;
import com.omertron.slackbot.model.HelpInfo;
import com.omertron.slackbot.model.sheets.GameLogRow;
import com.omertron.slackbot.model.sheets.PlayerInfo;
import com.omertron.slackbot.model.sheets.SheetInfo;
import com.omertron.slackbot.utils.PropertiesUtil;
import com.ullink.slack.simpleslackapi.SlackAttachment;
import com.ullink.slack.simpleslackapi.SlackChannel;
import com.ullink.slack.simpleslackapi.SlackPreparedMessage;
import com.ullink.slack.simpleslackapi.SlackSession;
import com.ullink.slack.simpleslackapi.SlackUser;
import com.ullink.slack.simpleslackapi.events.SlackMessagePosted;
import java.time.LocalDate;
import java.time.Period;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.TreeSet;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.builder.ToStringBuilder;
import org.apache.commons.lang3.builder.ToStringStyle;
import org.apache.commons.text.similarity.FuzzyScore;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class GoogleSheetsListener extends AbstractListener {

    private static final Logger LOG = LoggerFactory.getLogger(GoogleSheetsListener.class);
    private static final Pattern PAT_SHEETS = Pattern.compile("^\\Qwbb\\E(\\s\\w*)?(\\s.*)?",
            Pattern.CASE_INSENSITIVE);
    private static final List<String> CHANNELS = new ArrayList<>();
    private static final Map<String, PlayerInfo> PLAYERS = new HashMap<>();
    private static final FuzzyScore SCORE = new FuzzyScore(Locale.ENGLISH);
    // Cached Sheet Information
    private static final String SS_ID = "1Tbnvj3Colt5CnxlDUNk1L10iANm4jVUvJpD53mjKOYY";
    private static SheetInfo sheetInfo = null;
    private static final String SHORT_DATE_FORMAT = "EEE dd MMM";
    // Help data
    private static final Map<Integer, HelpInfo> HELP = new TreeMap<>();
    private static SlackAttachment helpMessage;
    // Sheet ranges
    private static final String RANGE_PLAYER_NAMES = "Stats!B4:D18";
    private static final String RANGE_NEXT_GAME_DATA = "Stats!R20:S31";
    private static final String RANGE_GAME_NAME = "Game Log!B";
    private static final String RANGE_GAME_CHOOSER = "Game Log!E";
    private static final String RANGE_GAME_ATTENDEES = "Game Log!F";
    private static final String RANGE_GAME_WINNERS = "Game Log!G";
    private static final String RANGE_GAME_OWNER = "Game Log!I";

    /**
     * Listens for commands to do with the Wirral Biscuits & Boardgame's Google spreadsheet
     *
     */
    public GoogleSheetsListener() {
        // Add the allowed channels
        CHANNELS.add("G3RU2Q5MG"); // bot test
        CHANNELS.add(PropertiesUtil.getProperty(Constants.WBB_CHANNEL_ID)); // WBB channel

        // Attempt to initialise the sheet reader
        if (!GoogleSheets.isAuthorised()) {
            GoogleSheets.initialise();
        }

        generateHelpMessage();

        // Get the static data
        getStaticData();
    }

    /**
     * Create the help message for the bot
     */
    private static void generateHelpMessage() {
        HELP.clear();
        // Add the help commands
        HELP.put(10, new HelpInfo("NEXT", "", "Get information on the next scheduled game for the group", false));
        HELP.put(21, new HelpInfo("CHOOSER", "Name",
                "Set *<name>* to be the chooser for the next game.\nIf blank, will clear the chooser.", false));
        HELP.put(22, new HelpInfo("ADD", "Name",
                "Add *<name>* to the play list for this game.\nIf blank, will add *YOU*", false));
        HELP.put(23, new HelpInfo("REMOVE", "Name",
                "Remove *<name>* to the play list for this game.\nIf blank, will remove *YOU*", false));
        HELP.put(24, new HelpInfo("OWNER", "Name",
                "Set *<name>* to be the owner of the game.\nIf blank, will clear the current name", false));
        HELP.put(31, new HelpInfo("GAME", "Game Name",
                "Sets the next game to be played to *<Game Name>*\nIf blank, will clear the current game name",
                false));
        HELP.put(32, new HelpInfo("WINNER", "Player",
                "Sets the winner of the game.\nIf blank, will clear the current winners.\nCan be multiple names comma separated.",
                false));
        HELP.put(39, new HelpInfo("NIGHT", "", "Displays the game night information.", false));

        helpMessage = new SlackAttachment();

        helpMessage.setFallback("Help commads for the bot");

        StringBuilder text = new StringBuilder(
                "The following commands are available from the game bot for this channel.\n\n");
        text.append("The spreadsheet for the group can be found ")
                .append("<https://docs.google.com/spreadsheets/d/").append(SS_ID).append("|*HERE*>\n\n");
        text.append("The following commands can be used to edit the sheet for the current game.\n");
        text.append("They should be typed on a line on thier own after the base command `WBB`.\n")
                .append("E.G. `WBB NEXT`\n");

        helpMessage.setPretext(text.toString());
        helpMessage.addMarkdownIn("fields");
        helpMessage.setColor("good");

        for (Map.Entry<Integer, HelpInfo> entry : HELP.entrySet()) {
            HelpInfo hi = entry.getValue();
            if (!hi.isAdmin()) {
                helpMessage.addField(hi.getFormattedCommand(), hi.getMessage(), false);
            }
        }

    }

    @Override
    public void onEvent(SlackMessagePosted event, SlackSession session) {
        // Channel On Which Message Was Posted
        SlackChannel msgChannel = event.getChannel();
        if (!authenticate(session, msgChannel, event.getSender())) {
            return;
        }

        Matcher m = PAT_SHEETS.matcher(event.getMessageContent());
        if (m.matches()) {
            String command = StringUtils.trimToNull(m.group(1)) == null ? "HELP" : m.group(1).toUpperCase().trim();
            String params = StringUtils.trimToNull(m.group(2));
            LOG.info("Command '{}' & params '{}'", command, params);

            // Do an initial read of the sheet information
            if (sheetInfo == null) {
                readSheetInfo();
            }

            switch (command) {
            case "HELP":
                session.sendMessage(msgChannel, "", helpMessage);
                break;
            case "NEXT":
                botUpdateChannel(session, event, E_GAME_DIE);
                readSheetInfo();
                session.sendMessage(msgChannel, "", createGameInfo(sheetInfo));
                break;
            case "ADD":
                addNameToNextGame(session, msgChannel, params, event.getSender());
                break;
            case "REMOVE":
                removeNameFromNextGame(session, msgChannel, params, event.getSender());
                break;
            case "GAME":
                updateGameName(session, msgChannel, params);
                break;
            case "WINNER":
                updateGenericPlayer(session, msgChannel, RANGE_GAME_WINNERS, params, "winner", true);
                break;
            case "OWNER":
                updateGenericPlayer(session, msgChannel, RANGE_GAME_OWNER, params, "owner", false);
                break;
            case "CHOOSER":
                updateGenericPlayer(session, msgChannel, RANGE_GAME_CHOOSER, params, "chooser", false);
                break;
            case "NIGHT":
                createGameNightMessage(session, msgChannel);
                break;
            default:
                session.sendMessage(msgChannel, "Sorry, '" + command + "' is not implemented yet.");
            }
        }
    }

    /**
     * Check the channel and user to see if the bot has been called from the correct place(s)
     *
     * @param session
     * @param msgChannel
     * @param msgSender
     * @return
     */
    private boolean authenticate(SlackSession session, SlackChannel msgChannel, SlackUser msgSender) {
        // Check the channel is WBB channel (or test D40EZ44QZ)
        if (!CHANNELS.contains(msgChannel.getId()) && !msgChannel.getId().startsWith("D")) {
            // Not the right channel
            LOG.debug("Sheets bot called from invalid channel: {}", msgChannel.getId());
            return false;
        }

        // Filter out the bot's own messages
        return !session.sessionPersona().getId().equals(msgSender.getId());
    }

    /**
     * Get the sheet information
     *
     * @return
     */
    public static SheetInfo getSheetInfo() {
        return getSheetInfo(false);
    }

    /**
     * Get the sheet information
     *
     * @param forceUpdate Force an update of the sheet information, default false
     * @return
     */
    public static SheetInfo getSheetInfo(boolean forceUpdate) {
        if (sheetInfo == null || forceUpdate) {
            readSheetInfo();
        }

        return sheetInfo;
    }

    /**
     * Retrieve and display the next game information from the sheet
     *
     * @param session
     * @param msgChannel
     */
    private synchronized static void readSheetInfo() {
        sheetInfo = new SheetInfo();
        ValueRange response = GoogleSheets.getSheetData(SS_ID, RANGE_NEXT_GAME_DATA);

        List<List<Object>> values = response.getValues();
        if (values != null && !values.isEmpty()) {
            for (List row : values) {
                if (!row.isEmpty()) {
                    decodeRowFromSheet(row);
                }
            }
        }

        GameLogRow row = readGameLogRow(sheetInfo.getLastRow());
        if (row.getAttendees() != null) {
            String players = row.getAttendees();
            for (String p : StringUtils.split(players, ",")) {
                sheetInfo.addPlayer(findPlayer(p));
            }
        }

        // Deal with the issue that the game ID may have been read as "#NAME?" due to the sheet recalculating
        if (sheetInfo.getNextGameId() <= 0) {
            LOG.info("Updated game ID from {} to {}", sheetInfo.getNextGameId(), row.getGameId());
            sheetInfo.setNextGameId(row.getGameId());
        }

        LOG.info("SheetInfo READ:\n{}",
                ToStringBuilder.reflectionToString(sheetInfo, ToStringStyle.MULTI_LINE_STYLE));
    }

    /**
     * Extract the information from the sheet row.
     *
     * @param row
     */
    private static void decodeRowFromSheet(List row) {
        String key, value;
        key = row.get(0).toString().toUpperCase();
        value = row.size() > 1 ? row.get(1).toString() : null;
        if (sheetInfo.addItem(key, value)) {
            LOG.info("Added: '{}'='{}'", key, value);
        } else {
            LOG.info("Unmatched row: '{}'='{}'", key, value);
        }
    }

    /**
     * Generate the next game attachment
     *
     * @return SlackAttachment
     */
    public static SlackAttachment createGameInfo() {
        if (sheetInfo == null) {
            readSheetInfo();
        }
        return createGameInfo(sheetInfo);
    }

    /**
     * Generate the next game attachment
     *
     * @param sheetInfo Google SheetInfo
     * @return SlackAttachment
     */
    public static SlackAttachment createGameInfo(SheetInfo sheetInfo) {
        SlackAttachment sa = new SlackAttachment();

        if (sheetInfo.getNextGameId() > 0) {
            BoardGameExtended game = getGameInfo(sheetInfo.getNextGameId());

            if (game != null) {
                sa.setTitle(game.getName());
                sa.setTitleLink(Constants.BGG_LINK_GAME + game.getId());
                sa.setFallback(game.getName() + " for " + sheetInfo.getFormattedDate(SHORT_DATE_FORMAT));
                sa.setThumbUrl(BoardGameListener.formatHttpLink(game.getThumbnail()));
            } else {
                sa.setTitle(sheetInfo.getGameName());
                sa.setTitleLink(Constants.BGG_LINK_GAME + sheetInfo.getNextGameId());
                sa.setFallback(sheetInfo.getGameName() + " for " + sheetInfo.getFormattedDate(SHORT_DATE_FORMAT));
                sa.setThumbUrl(sheetInfo.getGameImageUrl());
            }
            sa.setAuthorName("Chosen by " + sheetInfo.getGameChooser());
        } else {
            sa.setAuthorName(sheetInfo.getGameChooser() + " choose a game already!");
            sa.setFallback("No game chosen for " + sheetInfo.getFormattedDate(SHORT_DATE_FORMAT));
            sa.setTitle(sheetInfo.getGameChooser() + " has not chosen a game yet");
            sa.setThumbUrl(sheetInfo.getGameImageUrl());
        }

        sa.setText("Next game night is " + sheetInfo.getFormattedDate("EEEE, d MMMM"));

        if (!sheetInfo.getPlayers().isEmpty()) {
            sa.addField("Attendees", sheetInfo.getNameList(", "), true);
        }

        sa.addField("Pin Holder", sheetInfo.getPinHolder(), true);
        sa.addField("Next Chooser", sheetInfo.getNextChooser(), true);

        return sa;
    }

    private static BoardGameExtended getGameInfo(int bggId) {
        if (bggId > 0) {
            try {
                List<BoardGameExtended> results = BGG.getBoardGameInfo(bggId);
                if (results == null || results.isEmpty()) {
                    return null;
                }

                return results.get(0);
            } catch (BggException ex) {
                LOG.warn("Failed to get information from BGG on game ID {}", bggId, ex);
                return null;
            }
        }

        LOG.warn("No BGG Id given to find");
        return null;
    }

    /**
     * Read information from the sheet, such as user names and store in objects
     */
    private void getStaticData() {
        // get the player data
        ValueRange response = GoogleSheets.getSheetData(SS_ID, RANGE_PLAYER_NAMES);

        LOG.info("Getting players from sheet:");
        PLAYERS.clear();
        List<List<Object>> values = response.getValues();
        if (values != null && !values.isEmpty()) {
            PlayerInfo pi;
            for (List row : values) {
                if (!row.isEmpty()) {
                    pi = new PlayerInfo(row.get(0).toString(), row.get(1).toString());
                    if (row.size() > 2) {
                        pi.setBggUsername(row.get(2).toString());
                    }
                    LOG.info("\t{}", pi.toString());
                    PLAYERS.put(pi.getInitial(), pi);
                }
            }
        }
    }

    /**
     * Attempt to find the user from the parameters passed.<p>
     * If the name is blank or "me", use the first name of the user from their user profile.
     *
     * @param name Name to add
     * @param user Slack user details to use instead
     * @return The closest match to the user searched for.
     */
    private PlayerInfo decodeName(final String name, final SlackUser user) {
        // If blank name, user user details
        if (StringUtils.isBlank(name) || "me".equalsIgnoreCase(name)) {
            String firstName = StringUtils.split(user.getRealName(), " ")[0];
            LOG.debug("No name passed (or 'me'), using '{}' for search", firstName);
            // Try the first name
            return findPlayer(firstName);
        }

        // Search for the name
        return findPlayer(name);
    }

    /**
     * Attempt to find the player in the list of names from the sheet.
     *
     * @param search Name to search for
     * @return The closest match for the search name
     */
    private static PlayerInfo findPlayer(final String player) {
        String search = StringUtils.trimToEmpty(player).toUpperCase();
        LOG.info("Searching for player '{}'", search);

        if (PLAYERS.containsKey(search)) {
            LOG.info("Found direct inital match: {}", PLAYERS.get(search).toString());
            return PLAYERS.get(search);
        }

        PlayerInfo matchedPlayer = null;
        int bestMatch = 0;
        LOG.info("Searching player list for match to '{}'", search);
        for (PlayerInfo pi : PLAYERS.values()) {
            if (search.equalsIgnoreCase(pi.getInitial())) {
                LOG.info("Found direct inital match: {}", pi.toString());
                return pi;
            }

            int newScore = SCORE.fuzzyScore(search, pi.getName());
            if (newScore > bestMatch) {
                LOG.info("\tBetter match found for '{}' with '{}', new score={}", search, pi.getName(), newScore);
                bestMatch = newScore;
                matchedPlayer = pi;
            }
        }

        if (bestMatch < 5) {
            LOG.info("No definitive match found for '{}' (best score was {}), using 'Other'", search, bestMatch);
            return PLAYERS.get("O");
        } else if (matchedPlayer == null) {
            LOG.info("No match found for '{}', using 'Other'", search);
            return PLAYERS.get("O");
        } else {
            LOG.info("Matched '{}' to '{}' with score of {}", matchedPlayer.getName(), search, bestMatch);
            return matchedPlayer;
        }
    }

    /**
     * Add a player to the next game's player list
     *
     * @param session
     * @param msgChannel
     * @param nameToAdd
     * @param requestor
     */
    private void addNameToNextGame(SlackSession session, SlackChannel msgChannel, final String nameToAdd,
            final SlackUser requestor) {
        // If we have no sheet informatiom, then populate it
        if (sheetInfo == null) {
            readSheetInfo();
        }

        LOG.info("Current player list: {}", sheetInfo.getInitialList());

        PlayerInfo pi = decodeName(nameToAdd, requestor);

        if (sheetInfo.getInitialList().contains(pi.getInitial())) {
            // Already there
            String message = String.format("Player '%1$s' (%2$s) is already playing.", pi.getName(),
                    pi.getInitial());
            LOG.info(message);
            session.sendMessage(msgChannel, message);
            return;
        }

        // Add the player to the sheet info (so we can use the inital list).
        sheetInfo.addPlayer(pi);
        LOG.info("New player list: {}", sheetInfo.getInitialList());

        // Send the data to the sheet and output a message
        if (GoogleSheets.writeValueToCell(SS_ID, RANGE_GAME_ATTENDEES + sheetInfo.getLastRow(),
                sheetInfo.getInitialList())) {
            String message = String.format("Successfully added '%1$s' (%2$s) to the next game.", pi.getName(),
                    pi.getInitial());
            session.sendMessage(msgChannel, message);
        } else {
            String message = String.format("Failed to add '%1$s' (%2$s) to the next game.", pi.getName(),
                    pi.getInitial());
            session.sendMessage(msgChannel, message);
        }
    }

    /**
     * Remove a player from the next game's player list
     *
     * @param session
     * @param msgChannel
     * @param nameToAdd
     * @param requestor
     */
    private void removeNameFromNextGame(SlackSession session, SlackChannel msgChannel, final String nameToAdd,
            final SlackUser requestor) {
        // If we have no sheet informatiom, then populate it
        if (sheetInfo == null) {
            readSheetInfo();
        }

        LOG.info("Current player list: {}", sheetInfo.getInitialList());

        PlayerInfo pi = decodeName(nameToAdd, requestor);

        if (!sheetInfo.getInitialList().contains(pi.getInitial())) {
            // Player isn't there anyway!
            String message = String.format("Player '%1$s' (%2$s) is currently not scheduled to play.", pi.getName(),
                    pi.getInitial());
            LOG.info(message);
            session.sendMessage(msgChannel, message);
            return;
        }

        // Add the player to the sheet info (so we can use the inital list).
        sheetInfo.removePlayer(pi);
        LOG.info("New player list: {}", sheetInfo.getInitialList());

        // Send the data to the sheet and output a message
        if (GoogleSheets.writeValueToCell(SS_ID, RANGE_GAME_ATTENDEES + sheetInfo.getLastRow(),
                sheetInfo.getInitialList())) {
            String message = String.format("Successfully removed '%1$s' (%2$s) from the next game.", pi.getName(),
                    pi.getInitial());
            session.sendMessage(msgChannel, message);
        } else {
            String message = String.format("Failed to remove '%1$s' (%2$s) from the next game.", pi.getName(),
                    pi.getInitial());
            session.sendMessage(msgChannel, message);
        }
    }

    /**
     * Read a row of the game log
     *
     * @param row Row to read
     * @return Values in an object
     */
    private static GameLogRow readGameLogRow(int row) {
        String sheetRow = String.format("Game Log!A%1$d:I%1$d", row);
        LOG.info("Getting data from '{}'", sheetRow);
        ValueRange vr = GoogleSheets.getSheetData(SS_ID, sheetRow);
        return new GameLogRow(vr);
    }

    /**
     * Update the game name<p>
     * Blank or null will clear the game name
     *
     * @param session
     * @param msgChannel
     * @param gameName
     */
    private void updateGameName(SlackSession session, SlackChannel msgChannel, final String gameName) {
        LOG.info("Updating game name from '{}' to '{}'", sheetInfo.getGameName(), gameName);

        String message;
        // Send the data to the sheet and output a message
        if (GoogleSheets.writeValueToCell(SS_ID, RANGE_GAME_NAME + sheetInfo.getLastRow(), gameName)) {
            if (StringUtils.isBlank(gameName)) {
                message = "Successfully cleared the game name";
            } else {
                message = String.format("Successfully updated the game name to '%1$s'.", gameName);
            }
        } else {
            message = String.format("Failed to update the game name to '%1$s'.", gameName);
        }
        session.sendMessage(msgChannel, message);
    }

    /**
     * Method to update the sheet with a player or players for a given cell
     *
     * @param session
     * @param msgChannel
     * @param value
     * @param updateType
     * @param useInitials
     */
    private void updateGenericPlayer(SlackSession session, SlackChannel msgChannel, final String cellRef,
            final String value, final String updateType, boolean useInitials) {
        String message;
        boolean success;

        if (StringUtils.isBlank(value)) {
            // Blank will clear the current cell
            success = GoogleSheets.writeValueToCell(SS_ID, cellRef + sheetInfo.getLastRow(), "");
            message = String.format("Cleared the %1$s from the game", updateType);
        } else if (value.contains(",")) {
            // Value contains multiple people, so process accordingly
            Set<String> nameList = new TreeSet<>();
            // Split the given list
            for (String name : StringUtils.split(value, ",")) {
                PlayerInfo pi = findPlayer(name);
                if (useInitials) {
                    nameList.add(pi.getInitial());
                } else {
                    nameList.add(pi.getName());
                }
            }

            String concatNames = StringUtils.join(nameList, ",");
            success = GoogleSheets.writeValueToCell(SS_ID, cellRef + sheetInfo.getLastRow(), concatNames);
            message = String.format("Successfully updated the %1$s to '%2$s'.", updateType, concatNames);
        } else {
            // Assume a single person 
            PlayerInfo player = findPlayer(value);
            LOG.info("Setting {} to '{}' ({})", updateType, player.getName(), player.getInitial());
            String playerValue;
            if (useInitials) {
                playerValue = player.getInitial();
            } else {
                playerValue = player.getName();
            }
            success = GoogleSheets.writeValueToCell(SS_ID, cellRef + sheetInfo.getLastRow(), playerValue);
            message = String.format("Successfully updated the %1$s to '%2$s' (%3$s).", updateType, player.getName(),
                    player.getInitial());
        }

        // Send the data to the sheet and output a message
        if (!success) {
            message = String.format("Failed to update the %1$s to '%2$s'.", updateType, value);
        }
        session.sendMessage(msgChannel, message);
    }

    public static void createGameNightMessage(SlackSession session, SlackChannel msgChannel) {
        LocalDate now = LocalDate.now();
        Period diff = Period.between(now, sheetInfo.getGameDate());

        switch (diff.getDays()) {
        case 0:
            session.sendMessage(msgChannel, "Game night is tonight!! :grin:",
                    GoogleSheetsListener.createGameInfo());
            break;
        case 1:
            session.sendMessage(msgChannel, "Game night is tomorrow! :smile:",
                    GoogleSheetsListener.createGameInfo());
            break;
        default:
            session.sendMessage(msgChannel, GoogleSheetsListener.createSimpleNightMessage(sheetInfo, diff));
            break;
        }
    }

    /**
     * Create a simple formatted message about the future game night
     *
     * @param sheetInfo SheetInfo
     * @param diff Days to next game night
     * @return Slack Prepared Message
     */
    public static SlackPreparedMessage createSimpleNightMessage(SheetInfo sheetInfo, Period diff) {
        SlackPreparedMessage.Builder spm = new SlackPreparedMessage.Builder().withUnfurl(false);

        StringBuilder sb = new StringBuilder("Game night is ");
        sb.append(sheetInfo.getFormattedDate("EEEE, d MMMM")).append(", still ").append(diff.getDays())
                .append(" days away\n");

        if (StringUtils.isBlank(sheetInfo.getGameChooser())) {
            sb.append("There is no-one to chose the next game!!! :astonished:");
        } else {
            if ("All".equalsIgnoreCase(sheetInfo.getGameChooser())) {
                sb.append("The group is choosing :open_mouth:");
            } else if ("Other".equalsIgnoreCase(sheetInfo.getGameChooser())) {
                sb.append("It's someone else's turn to choose :open_mouth:");
            } else {
                sb.append("It's *").append(sheetInfo.getGameChooser()).append("'s* turn to choose");
            }

            if (sheetInfo.getNextGameId() <= 0) {
                // There's no game ID, this could be because there's no game selected or an error reading
                if (StringUtils.isBlank(sheetInfo.getGameName())) {
                    // No game chosen
                    LOG.error("SheetInfo READ:\n{}",
                            ToStringBuilder.reflectionToString(sheetInfo, ToStringStyle.MULTI_LINE_STYLE));
                    sb.append(", but no game has been selected yet :angry:\n");
                } else {
                    // Error with reading the sheet or with google's API
                    sb.append(" and *").append(sheetInfo.getGameName()).append("* has been chosen.\n");
                }
            } else {
                sb.append(" and *").append(SlackBot.formatLink(Constants.BGG_LINK_GAME + sheetInfo.getNextGameId(),
                        sheetInfo.getGameName())).append("* has been picked.\n");
            }

        }

        // Who's attending?
        if (sheetInfo.getPlayers().isEmpty()) {
            sb.append("No-one has said they are going!");
        } else {
            sb.append(sheetInfo.getNameList(", ")).append(" are attending");
        }

        spm.withMessage(sb.toString());

        return spm.build();
    }

    /**
     * UNFINISHED
     *
     * @param user
     * @param gameId
     * @return
     */
    private SlackAttachment getGameStats(String user, int gameId) {
        SlackAttachment sa = new SlackAttachment();
        sa.setTitle(user);

        CollectionItemWrapper collectionList;
        try {
            collectionList = BGG.getCollectionInfo(user, Integer.toString(gameId), null, null, false);
        } catch (BggException ex) {
            LOG.warn("Failed to get collection details for {}, game ID {}", user, gameId, ex);
            return null;
        }

        if (collectionList.getItems().isEmpty()) {
            LOG.info("User '{} has no plays for this game");

            sa.addField("Plays", "0", true);
            sa.addField("Last Play", "Never", true);

            return sa;
        }

        for (CollectionItem item : collectionList.getItems()) {
            LOG.info("\t{}", item.toString());
        }

        CollectionItem item = collectionList.getItems().get(0);
        sa.addField("Plays", Integer.toString(item.getNumPlays()), true);

        return sa;
    }
}