Lichess4545/Chesster

View on GitHub
src/commands/subscription.ts

Summary

Maintainability
F
4 days
Test Coverage
F
10%
// -----------------------------------------------------------------------------
// Commands and helpers related to subscriptions
// -----------------------------------------------------------------------------
import _ from 'lodash'
import winston from 'winston'
import { Op } from 'sequelize'

import * as league from '../league'
import * as db from '../models'
import { SlackBot, CommandMessage } from '../slack'
import { isDefined } from '../utils'

// The emitter we will use.
import { EventEmitter } from 'events'
class ChessLeagueEmitter extends EventEmitter {}
export const emitter = new ChessLeagueEmitter()

type Context = any
type Callback = any

const events: string[] = [
    // 'a-game-starts',
    // 'a-game-is-scheduled',
    // 'a-game-is-over',
    // 'a-pairing-is-released',
]

// -----------------------------------------------------------------------------
// Format the help response message
// -----------------------------------------------------------------------------
function formatHelpResponse(bot: SlackBot) {
    const leagueNames = _.map(league.getAllLeagues(bot), 'name')

    return (
        'The subscription system supports the following commands: \n' +
        '1. `tell <listener> when <event> in <league> for <target-user-or-team>` to ' +
        ' subscribe to an event\n' +
        '2. `subscription list` to list your current subscriptions\n' +
        '3. `subscription remove <id>` to remove a specific subscription\n' +
        '\n--------------------------------------------------------------------\n' +
        'For command 1, these are the options: \n' +
        '1. `<listener>` is either `me` to subscribe yourself or ' +
        ' `my-team-channel` to subscribe your team channel (_Note: you must be ' +
        ' be team captain to use `my-team-channel`_).\n' +
        '2. `<event>` is one of:`' +
        events.join('` or `') +
        '`\n' +
        '3. `<league>` is one of: `' +
        leagueNames.join('` or `') +
        '`\n' +
        '4. `<target-user-or-team>` is the target username or teamname that you are ' +
        'subscribing to (_Note: You can use `my-team` to target your team_).'
    )
}

// -----------------------------------------------------------------------------
// tell them they chose an invalid league
// -----------------------------------------------------------------------------
function formatInvalidLeagueResponse(bot: SlackBot) {
    const leagueNames = _.map(league.getAllLeagues(bot), 'name')

    return (
        "You didn't specify a valid league. These are your options:\n" +
        '```' +
        leagueNames.join('\n') +
        '```'
    )
}

// -----------------------------------------------------------------------------
// Tell them they can only listen for themselves
// -----------------------------------------------------------------------------
function formatCanOnlyListenForYouResponse(listener: string) {
    if (_.isEqual(listener, 'my-team-channel')) {
        return (
            "You can't subscribe " +
            listener +
            " because you aren't the team captain." +
            " As a non-captain you can only subscribe yourself. Use 'me' as " +
            ' listener.'
        )
    } else {
        return (
            "You can't subscribe " +
            listener +
            " because you aren't a moderator." +
            " As a non-moderator you can only subscribe yourself. Use 'me' as " +
            ' listener.'
        )
    }
}

// -----------------------------------------------------------------------------
// This is not a listener that we recognize
// -----------------------------------------------------------------------------
function formatInvalidListenerResponse(listener: string) {
    return (
        "I don't recognize '" + listener + "'. Please use 'me' as the listener."
    )
}

// -----------------------------------------------------------------------------
// Format the invalid event handler
// -----------------------------------------------------------------------------
function formatInvalidEventResponse(event: string) {
    return (
        '`' +
        event +
        '` is not one of the events that I recognize. Use one of: ' +
        '```' +
        events.join('\n') +
        '```'
    )
}

// -----------------------------------------------------------------------------
// Format the response for when the source is not valid
// -----------------------------------------------------------------------------
function formatInvalidSourceResponse(source: string) {
    return (
        '`' +
        source +
        '` is not a valid source of that event. Please target a valid user or team.'
    )
}

// -----------------------------------------------------------------------------
// Format the response for when the user is not on a team
// -----------------------------------------------------------------------------
function formatNoTeamResponse() {
    return "You can't use my-team or my-team-channel since you don't have a team right now."
}

// -----------------------------------------------------------------------------
// Format the a-game-is-scheduled response
// -----------------------------------------------------------------------------
export function formatAGameIsScheduled(
    bot: SlackBot,
    target: string,
    context: Context
) {
    // TODO: put these date formats somewhere, probably config?
    const friendlyFormat = 'ddd @ HH:mm'
    const member = bot.getSlackUserFromNameOrID(target)
    let message = ''
    const fullFormat = 'YYYY-MM-DD @ HH:mm UTC'
    const realDate = context.result.date.format(fullFormat)
    if (member && member.tz_offset) {
        const targetDate = context.result.date
            .clone()
            .utcOffset(member.tz_offset / 60)
        context.yourDate = targetDate.format(friendlyFormat)
        message = `${context.white.lichess_username} vs ${context.black.lichess_username} in ${context.league.name} has been scheduled for ${realDate}, which is ${context.yourDate} for you.`
    } else {
        message = `${context.white.lichess_username} vs ${context.black.lichess_username} in ${context.league.name} has been scheduled for ${realDate}.`
    }
    return message
}

// -----------------------------------------------------------------------------
// Format the A Game Starts response.
// -----------------------------------------------------------------------------
export function formatAGameStarts(
    bot: SlackBot,
    target: string,
    context: Context
) {
    return `${context.white.name} vs ${context.black.name} in ${context.league.name} has started: ${context.details.game_link}`
}

// -----------------------------------------------------------------------------
// Format the a game is over response.
// -----------------------------------------------------------------------------
export function formatAGameIsOver(
    bot: SlackBot,
    target: string,
    context: Context
) {
    return `${context.white.name} vs ${context.black.name} in ${context.league.name} is over. The result is ${context.details.result}.`
}

// -----------------------------------------------------------------------------
// Processes a tell command.
//
// A tell command is made up like so:
//      tell <listener> when <event> in <league> for <source>
// -----------------------------------------------------------------------------
function processTellCommand(
    bot: SlackBot,
    message: CommandMessage
): Promise<string> {
    return new Promise((resolve, reject) => {
        const requester = bot.getSlackUserFromNameOrID(message.user)
        if (!isDefined(requester)) return
        const components = message.text.split(' ')
        const args = _.map(components.slice(0, 7), _.toLower)
        let sourceName = components.splice(7).join(' ')
        const tell = args[0]
        let listener = args[1]
        const when = args[2]
        const event = args[3]
        const _in = args[4]
        const targetLeague = args[5]
        const _for = args[6]

        // Ensure the basic command format is valid
        if (
            _.difference([tell, when, _in, _for], ['tell', 'when', 'in', 'for'])
                .length > 0
        ) {
            const response = formatHelpResponse(bot)
            return resolve("I didn't understand you.\n" + response)
        }

        // Ensure they chose a valid league.
        const _league = league.getLeague(bot, targetLeague)
        if (_.isUndefined(_league)) {
            return resolve(formatInvalidLeagueResponse(bot))
        }
        message.league = _league
        let team = message.league.getTeamByPlayerName(
            requester.lichess_username
        )
        const captainName =
            team && _(team.players).filter('isCaptain').map('username').value()
        const isCaptain =
            captainName &&
            _.isEqual(
                _.toLower(captainName[0]),
                _.toLower(requester.lichess_username)
            )

        const possibleListeners = ['me', 'my-team-channel']

        if (
            !(
                _.isEqual(listener, 'me') ||
                _league.isModerator(requester.lichess_username) ||
                (_.isEqual(listener, 'my-team-channel') && isCaptain)
            )
        ) {
            return resolve(formatCanOnlyListenForYouResponse(listener))
        }

        if (!_.includes(possibleListeners, listener)) {
            return resolve(formatInvalidListenerResponse(listener))
        }

        // TODO: hrmmm. this seems bad.
        let target: string = ''
        if (_.isEqual(listener, 'me')) {
            listener = 'you'
            target = requester.lichess_username
        } else if (_.isEqual(listener, 'my-team-channel')) {
            if (!team) {
                return formatNoTeamResponse()
            }
            listener = 'your team channel'
            target = 'channel_id:' + team.slack_channel
        }

        // Ensure the event is valid
        if (!_.includes(events, event)) {
            return resolve(formatInvalidEventResponse(event))
        }

        if (_.isEqual(sourceName, 'my-team')) {
            if (!team) {
                return resolve(formatNoTeamResponse())
            }
            sourceName = team.name
        }
        // Ensure the source is a valid user or team within slack
        const source = bot.getSlackUserFromNameOrID(sourceName)
        const teams = _league.getTeams()
        team = _.find(teams, (t) => {
            return t.name.toLowerCase() === sourceName.toLowerCase()
        })
        if (_.isUndefined(source) && _.isUndefined(team)) {
            return resolve(formatInvalidSourceResponse(sourceName))
        }
        if (!_.isUndefined(source)) {
            sourceName = source.lichess_username
        } else if (!_.isUndefined(team)) {
            sourceName = team.name
        }
        return db.Subscription.findOrCreate({
            where: {
                requester: requester.lichess_username.toLowerCase(),
                source: sourceName.toLowerCase(),
                event: event.toLowerCase(),
                league: _league.name.toLowerCase(),
                target: target.toLowerCase(),
            },
        })
            .then(() =>
                resolve(
                    `Great! I will tell ${target} when ${event} for ${sourceName} in ${_league.name}`
                )
            )
            .catch((error) => reject(error))
    })
}

// -----------------------------------------------------------------------------
// Processes the subscriptions command
//
// `subscription list` (by itself) simply lists your subscriptions with ID #s
// -----------------------------------------------------------------------------
function processSubscriptionListCommand(
    bot: SlackBot,
    message: CommandMessage
): Promise<string> {
    return new Promise((resolve, reject) => {
        const requester = bot.getSlackUserFromNameOrID(message.user)
        if (!isDefined(requester)) return
        return db.Subscription.findAll({
            where: {
                requester: requester.lichess_username.toLowerCase(),
            },
            order: [['id', 'ASC']],
        })
            .then((subscriptions) => {
                let response = ''
                _.each(subscriptions, (subscription) => {
                    const context: Context = subscription.get()
                    if (_.startsWith(context.target, 'channel_id:')) {
                        context.target = 'your team channel'
                    }
                    response += `\nID ${context.id} -> tell ${context.target} when ${context.event} for ${context.source} in ${context.league}`
                })
                if (response.length === 0) {
                    response = 'You have no subscriptions'
                }

                return resolve(response)
            })
            .catch(reject)
    })
}

// -----------------------------------------------------------------------------
// Processes the remove subscription command
//
// `subscription remove <id>` removes subscription with ID: <id>
// -----------------------------------------------------------------------------
function processSubscriptionRemoveCommand(
    bot: SlackBot,
    message: CommandMessage,
    id: string
): Promise<string> {
    return new Promise((resolve, reject) => {
        const requester = bot.getSlackUserFromNameOrID(message.user)
        if (!isDefined(requester)) return
        return db.Subscription.findAll({
            where: {
                requester: requester.lichess_username.toLowerCase(),
                id,
            },
        }).then((subscriptions) => {
            if (subscriptions.length === 0) {
                return resolve('That is not a valid subscription id')
            } else if (subscriptions.length === 1) {
                return subscriptions[0].destroy().then(() => {
                    return resolve('Subscription deleted')
                })
            } else {
                return reject('This should never occur')
            }
        })
    })
}

// -----------------------------------------------------------------------------
// Register an event + message handler
// -----------------------------------------------------------------------------
export function register(bot: SlackBot, eventName: string, cb: Callback) {
    // Ensure this is a known event.
    events.push(eventName)

    // Handle the event when it happens
    emitter.on(eventName, async (_league, sources, context) => {
        return getListeners(bot, _league, sources, eventName).then(
            (targets) => {
                const allDeferreds: Promise<void>[] = targets.map(
                    (target) =>
                        new Promise((resolve, reject) => {
                            const message = cb(bot, target, _.clone(context))
                            if (_.startsWith(target, 'channel_id:')) {
                                const channelID = _.toUpper(target.substr(11))
                                bot.say({
                                    channel: channelID,
                                    text: message,
                                    attachments: [],
                                })
                            } else {
                                bot.startPrivateConversation([target])
                                    .then((convo) => {
                                        bot.say({
                                            channel: convo.channel.id,
                                            text: message,
                                        })
                                        resolve()
                                    })
                                    .catch((error) => {
                                        winston.error(JSON.stringify(error))
                                        reject(error)
                                    })
                            }
                        })
                )
                return Promise.all(allDeferreds)
            }
        )
    })
}

// -----------------------------------------------------------------------------
// Get listeners for a given event and source
// -----------------------------------------------------------------------------
export function getListeners(
    bot: SlackBot,
    _league: league.League,
    sources: string[],
    event: string
): Promise<string[]> {
    return new Promise((resolve, reject) => {
        sources = _.map(sources, _.toLower)
        // These are names of users, not users. So we have to go get the real
        // user and teams. This is somewhat wasteful - but I'm not sure whether
        // this is the right design or if we should pass in users to this point.
        const teamNames = _(sources)
            .map((s) => _league.getTeamByPlayerName(s))
            .filter(isDefined)
            .map('name')
            .map(_.toLower)
            .value()
        const possibleSources = _.concat(sources, teamNames)
        return db.Subscription.findAll({
            where: {
                league: _league.name.toLowerCase(),
                source: {
                    [Op.in]: possibleSources,
                },
                event: event.toLowerCase(),
            },
        }).then((subscriptions) => {
            return resolve(_(subscriptions).map('target').uniq().value())
        })
    })
}

// -----------------------------------------------------------------------------
// Format the not a moderator response
// -----------------------------------------------------------------------------
function formatNotModerator() {
    return "You are not a moderator and so you can't run this command"
}

// -----------------------------------------------------------------------------
// Process the subscribe teams command.
//
// A tell command is made up like so:
//      tell <listener> when <event> in <league> for <source>
// -----------------------------------------------------------------------------
function processTeamSubscribeCommand(
    bot: SlackBot,
    message: CommandMessage
): Promise<string> {
    return new Promise((resolve, reject) => {
        if (!isDefined(message.member)) {
            return
        }
        const _leagueOr = league.getLeague(bot, '45+45')
        if (!isDefined(_leagueOr)) return
        const _league: league.League = _leagueOr
        // Needed for isModerator to work
        message.league = _league
        if (!_league.isModerator(message.member.lichess_username)) {
            return resolve(formatNotModerator())
        }
        const teams = _league.getTeams()
        let processed = 0
        let missingChannel = 0
        const promises = teams.map((team) => {
            if (!team.slack_channel) {
                missingChannel++
            } else {
                const target = 'channel_id:' + team.slack_channel
                processed++
                ;['a-game-starts', 'a-game-is-over'].forEach((event) => {
                    return db.Subscription.findOrCreate({
                        where: {
                            requester: 'chesster',
                            source: team.name.toLowerCase(),
                            event: event.toLowerCase(),
                            league: _league.name.toLowerCase(),
                            target: target.toLowerCase(),
                        },
                    })
                })
            }
        })
        return Promise.all(promises)
            .then(() => {
                return resolve(
                    `Processed ${processed} teams. ${missingChannel} are missing channels.`
                )
            })
            .catch(reject)
    })
}

// -----------------------------------------------------------------------------
// Handler the tell me when command
// -----------------------------------------------------------------------------
export async function tellMeWhenHandler(
    bot: SlackBot,
    message: CommandMessage
) {
    const convo = await bot.startPrivateConversation([message.user])
    return processTellCommand(bot, message)
        .then((response) => {
            bot.say({
                channel: convo.channel.id,
                text: response,
            })
        })
        .catch((error) => {
            winston.error(JSON.stringify(error))
            bot.say({
                channel: convo.channel.id,
                text:
                    "I'm sorry, but an error occurred processing this subscription command",
            })
        })
}

// -----------------------------------------------------------------------------
// Handle the help message command
// -----------------------------------------------------------------------------
export async function helpHandler(bot: SlackBot, message: CommandMessage) {
    const convo = await bot.startPrivateConversation([message.user])
    bot.say({
        channel: convo.channel.id,
        text: formatHelpResponse(bot),
    })
}

// -----------------------------------------------------------------------------
// Handle the subscription list command.
// -----------------------------------------------------------------------------
export async function listHandler(bot: SlackBot, message: CommandMessage) {
    const convo = await bot.startPrivateConversation([message.user])
    return processSubscriptionListCommand(bot, message)
        .then((response) => {
            bot.say({
                channel: convo.channel.id,
                text: response,
            })
        })
        .catch((error) => {
            bot.say({
                channel: convo.channel.id,
                text:
                    "I'm sorry, but an error occurred processing this subscription command",
            })
            winston.error(JSON.stringify(error))
        })
}

// -----------------------------------------------------------------------------
// Handle the subscription remove command.
// -----------------------------------------------------------------------------
export async function removeHandler(bot: SlackBot, message: CommandMessage) {
    const convo = await bot.startPrivateConversation([message.user])
    return processSubscriptionRemoveCommand(bot, message, message.matches[1])
        .then((response) => {
            bot.say({
                channel: convo.channel.id,
                text: response,
            })
        })
        .catch((error) => {
            bot.say({
                channel: convo.channel.id,
                text:
                    "I'm sorry, but an error occurred processing this subscription command",
            })
            winston.error(JSON.stringify(error))
        })
}

// -----------------------------------------------------------------------------
// Handle the subscribe teams command
// -----------------------------------------------------------------------------
export async function subscribeTeams(bot: SlackBot, message: CommandMessage) {
    const convo = await bot.startPrivateConversation([message.user])
    return processTeamSubscribeCommand(bot, message)
        .then((response) => {
            bot.say({
                channel: convo.channel.id,
                text: response,
            })
        })
        .catch((error) => {
            bot.say({
                channel: convo.channel.id,
                text:
                    "I'm sorry, but an error occurred processing this subscription command",
            })
            winston.error(JSON.stringify(error))
        })
}