Lichess4545/Chesster

View on GitHub
src/slack.ts

Summary

Maintainability
F
5 days
Test Coverage
F
13%
// -----------------------------------------------------------------------------
// Bot / Slack related helpers
// -----------------------------------------------------------------------------
import { RTMClient } from '@slack/rtm-api'
import {
    WebClient,
    WebAPICallResult,
    ChatPostMessageArguments,
} from '@slack/web-api'
import _ from 'lodash'
import * as winston from 'winston'
import moment from 'moment'

import * as league from './league'
import * as heltour from './heltour'
import * as fuzzy from './fuzzy_match'
import * as models from './models'
import SlackLogger, { LogWithPrefix } from './logging'
import { isDefined } from './utils'
import * as config from './config'

export type SlackUserID = string
export type SlackUserName = string
export type SlackChannelName = string
export type SlackTeamID = string
export interface SlackResponseMetadata {
    next_cursor: string
}
export interface SlackProfile {
    avatar_hash: string
    status_text: string
    status_emoji: string
    real_name: string
    display_name: string
    real_name_normalized: string
    display_name_normalized: string
    email: string
    image_24: string
    image_32: string
    image_48: string
    image_72: string
    image_192: string
    image_512: string
    team: string
}
export interface SlackUser {
    id: SlackUserID
    team_id: SlackTeamID
    name: SlackUserName
    deleted: boolean
    color: string
    real_name: string
    tz: string
    tz_label: string
    tz_offset: number
    profile: SlackProfile
    is_admin: boolean
    is_owner: boolean
    is_primary_owner: boolean
    is_restricted: boolean
    is_ultra_restricted: boolean
    is_bot: boolean
    updated: number
    is_app_user: boolean
    has_2fa: boolean
}
export interface SlackUserListResponse extends WebAPICallResult {
    ok: boolean
    members: SlackUser[]
    cache_ts: number
    response_metadata: SlackResponseMetadata
}
export interface SlackChannelTopicOrPurpose {
    value: string
    creator: SlackUserID
    last_set: number
}
export interface SlackChannelMembersList extends WebAPICallResult {
    ok: boolean
    members: string[]
    response_metadata: SlackResponseMetadata
}

export interface SlackChannel {
    id: string
    name: string
    is_channel: boolean
    is_group: boolean
    is_im: boolean
    created: number
    creator: SlackUserID
    is_archived: boolean
    is_general: boolean
    name_normalized: SlackChannelName
    is_shared: boolean
    is_org_shared: boolean
    is_member: boolean
    is_private: boolean
    is_mpim: boolean
    user?: string
    topic: SlackChannelTopicOrPurpose
    purpose: SlackChannelTopicOrPurpose
    previous_names: SlackChannelName[]
    num_members: number
    tz: string
    tz_offset: number
}
export interface SlackChannelListResponse extends WebAPICallResult {
    ok: boolean
    channels: SlackChannel[]
    cache_ts: number
    response_metadata: SlackResponseMetadata
}
export interface SlackChannelInfoResponse extends WebAPICallResult {
    ok: boolean
    channel: SlackChannel
}
export interface SlackUserInfoResponse extends WebAPICallResult {
    ok: boolean
    user: SlackUser
}
export interface SlackConversationChannel {
    id: string
}
export interface SlackConversation extends WebAPICallResult {
    ok: boolean
    channel: SlackConversationChannel
}
interface SlackEntityWithNameAndId {
    id: string
    name?: string
    lichess_username?: string
}
export interface SlackTeam {
    domain: string
    id: string
    name: string
}
interface SlackBotSelf {
    id: string
    name: string
}

export interface LeagueMember extends SlackUser {
    lichess_username: string
}

export function localTime(lm: LeagueMember, datetime: moment.Moment) {
    return datetime.clone().tz(lm.tz)
}

export function isLeagueMember(l: LeagueMember | undefined): l is LeagueMember {
    return l !== undefined
}

export interface SlackAttachmentField {
    title: string
    value: string
    short: boolean
}

export interface SlackAttachment {
    fallback: string
    color: string
    pretext: string
    author_name: string
    author_link: string
    author_icon: string
    title: string
    title_link: string
    text: string
    fields: SlackAttachmentField[]
    image_url: string
    thumb_url: string
    footer: string
    footer_icon: string
    ts: number
}

export interface SlackMessage {
    type: 'message' | 'app_mention'
    subtype: string
    client_msg_id: string
    user: SlackUserID
    bot_id?: SlackUserID
    channel: string
    text: string
    ts: string
    team: string
    // blocks: [ { type: 'rich_text', block_id: 'I4jc1', elements: [Array] } ],
    source_team: string
    user_team: string
    event_ts: string
    attachments: SlackAttachment[]
}

export interface ChessterMessage {
    type: 'message'
    user: SlackUserID
    channel: SlackChannel
    text: string
    member?: LeagueMember
    league?: league.League
    isModerator?: boolean
    ts: string
    attachments: SlackAttachment[]
    isPingModerator: boolean
}

export interface CommandMessage extends ChessterMessage {
    matches: RegExpMatchArray
}

export interface LeagueCommandMessage extends ChessterMessage {
    member: LeagueMember
    league: league.League
    isModerator: boolean
    matches: RegExpMatchArray
}

// -------------------------------------------------------------------------------
// Callback related types
export type HearsEventType =
    | 'ambient'
    | 'direct_message'
    | 'direct_mention'
    | 'bot_message'

export type MiddlewareFn = (
    bot: SlackBot,
    message: CommandMessage
) => CommandMessage

// export type ChessterCallbackFn = (bot: SlackBot, message: CommandMessage) => void
export type CommandCallbackFn = (bot: SlackBot, message: CommandMessage) => void
export type LeagueCommandCallbackFn = (
    bot: SlackBot,
    message: LeagueCommandMessage
) => void

export interface CommandEventOptions {
    type: 'command'
    patterns: RegExp[]
    messageTypes: HearsEventType[]
    middleware?: MiddlewareFn[]
    callback: CommandCallbackFn
}
export interface LeagueCommandEventOptions {
    type: 'league_command'
    patterns: RegExp[]
    messageTypes: HearsEventType[]
    middleware?: MiddlewareFn[]
    callback: LeagueCommandCallbackFn
}

export type SlackRTMEventListenerOptions =
    | CommandEventOptions
    | LeagueCommandEventOptions

function wantsBotMessage(options: SlackRTMEventListenerOptions) {
    return options.messageTypes.findIndex((t) => t === 'bot_message') !== -1
}
function wantsDirectMessage(options: SlackRTMEventListenerOptions) {
    return options.messageTypes.findIndex((t) => t === 'direct_message') !== -1
}
function wantsDirectMention(options: SlackRTMEventListenerOptions) {
    return options.messageTypes.findIndex((t) => t === 'direct_mention') !== -1
}
function wantsAmbient(options: SlackRTMEventListenerOptions) {
    return options.messageTypes.findIndex((t) => t === 'ambient') !== -1
}

export type OnEventType = 'member_joined_channel'

export interface MemberJoinedChannel {
    type: 'member_joined_channel'
    user: string
    channel: string
    channel_type: string
    team: string
    inviter: string
}
export type OnEvent = MemberJoinedChannel
export function isMemberJoinedChannelEvent(
    event: OnEvent
): event is MemberJoinedChannel {
    return event.type === 'member_joined_channel'
}
export type SlackOnCallbackFn = (bot: SlackBot, event: OnEvent) => void
export interface OnOptions {
    event: OnEventType
    callback: SlackOnCallbackFn
}

const SECONDS = 1000 // ms

const slackIDRegex = (module.exports.slackIDRegex = /<@([^\s]+)>/)

class StopControllerError extends Error {
    constructor(error: string) {
        super(error)
    }
}

export function appendPlayerRegex(command: string, optional: boolean) {
    /*
     * regex explanation
     * (?:           non capturing group, don't care about the space and @ part
     * @?            @ is optional (accepts "@user" or "user")
     * ([^\\s]+)     match one more more non-whitespace characters and capture it.  This
     *               is what will show up in the match[1] place.
     * )
     */
    const playerRegex = command + '(?: @?([^\\s]+))'

    if (optional) {
        // If the username is optional (as in the "rating" command), append
        // a ? to the whole player matching regex.
        return new RegExp(playerRegex + '?', 'i')
    }

    return new RegExp(playerRegex, 'i')
}

/*
   updates the user list
   updates the channel lists
   then repeats in new thread

   if it encounters an error it will exit the process with exit code 1
*/
function criticalPath<T>(promise: Promise<T>) {
    exceptionLogger(promise).catch(() => {
        winston.error(
            'An exception was caught in a critical code-path. I am going down.'
        )
        process.exit(1)
    })
    return promise
}

// a promise and ensures that uncaught exceptions are logged.
const exceptionLogger = <T>(promise: Promise<T>) =>
    new Promise((resolve, reject) => {
        promise.then(resolve).catch((e) => {
            const errorLog =
                'An error occurred:' +
                '\nDatetime: ' +
                new Date() +
                '\nError: ' +
                JSON.stringify(e) +
                '\nStack: ' +
                e.stack
            winston.error(errorLog)
            reject(e)
        })
    })

// -----------------------------------------------------------------------------
// Various middleware
// -----------------------------------------------------------------------------
export function requiresModerator(
    bot: SlackBot,
    message: CommandMessage
): CommandMessage {
    if (_.isUndefined(message.league)) {
        throw new Error('requiresModerator MUST be called after withLeague.')
    }
    if (!message.league) {
        throw new Error('Not in a league context')
    }
    if (!message.isModerator) {
        bot.reply(
            message,
            `You are not a moderator of the ${message.league.name} league. Your temerity has been logged.`
        )
        throw new StopControllerError('Not a moderator.')
    }
    return message
}

function findLeagueByMessageText(bot: SlackBot, message: ChessterMessage) {
    const allLeagues = league.getAllLeagues(bot)
    const leagueTargets: string[] = []
    const targetToLeague: Record<string, league.League> = {}
    function add_target(l: league.League, target: string) {
        leagueTargets.push(target)
        targetToLeague[target] = l
    }
    _.each(allLeagues, (l) => {
        add_target(l, l.name)
        _.each(l.config.alsoKnownAs, (aka) => add_target(l, aka))
    })

    // Now fuzzy match them based on each arg in the message
    const matches: fuzzy.Result[][] = []
    const args = message.text.split(' ')
    _.each(args, (arg) => {
        const results = fuzzy.rankChoices(arg.toLowerCase(), leagueTargets)
        matches.push(results)
    })
    const bestMatches = fuzzy.findBestMatches(_.flatten(matches))
    const possibleLeagues: Record<string, league.League> = {}
    _.each(bestMatches, (match) => {
        const l = targetToLeague[match.value]
        possibleLeagues[l.name] = l
    })
    const matchingLeagueNames = _.keys(possibleLeagues)
    if (matchingLeagueNames.length > 1) {
        throw new StopControllerError('Ambiguous leagues.')
    }
    if (matchingLeagueNames.length === 1) {
        return _.values(possibleLeagues)[0]
    }
    return null
}

function getLeague(
    bot: SlackBot,
    message: ChessterMessage,
    channelOnly: boolean
): league.League | undefined {
    message.league = undefined

    // generate a set of possible target leagues
    let l
    // If they didn't ask for a specific league, then we will use the channel
    // to determine it
    let targetLeague
    const channel = message.channel
    if (channel && channel.name) {
        targetLeague = bot.config.channelMap[channel.name]
        l = league.getLeague(bot, targetLeague)
        if (l) {
            message.league = l
            return l
        }
    }

    // If no channel name matched, try the channel Id this is necessary for private channels
    // it makes me sad ... :(
    if (channel && channel.id) {
        const channelId = channel.id
        targetLeague = bot.config.channelMap[channelId]
        if (targetLeague) {
            l = league.getLeague(bot, targetLeague)
            if (l) {
                message.league = l
                return l
            }
        }
    }

    // See if the person asked for a specific league first
    if (!channelOnly) {
        l = findLeagueByMessageText(bot, message)
        if (l) {
            message.league = l
            return l
        }
    }

    return undefined
}

export function requiresLeague(
    bot: SlackBot,
    message: CommandMessage
): CommandMessage {
    if (!message.league) {
        bot.reply(
            message,
            'This command requires you to specify the league you are interested in. Please include that next time'
        )
        throw new StopControllerError('No league specified')
    }
    return message
}

export class SlackEntityLookup<SlackEntity extends SlackEntityWithNameAndId> {
    public byName: Record<string, SlackEntity> = {}
    public byId: Record<string, SlackEntity> = {}
    public nameDuplicates: Record<string, SlackEntity[]> = {}
    public idDuplicates: Record<string, SlackEntity[]> = {}
    private log: LogWithPrefix

    constructor(
        public slackName: string,
        public typePostfix: string,
        public idStringPrefix: string
    ) {
        this.log = new LogWithPrefix(`[Slack${typePostfix}(${slackName})]`)
    }

    clear() {
        this.byName = {}
        this.byId = {}
    }

    _addByIdWithDuplicate(id: string, entity: SlackEntity) {
        if (isDefined(this.byId[id])) {
            this.idDuplicates[id].push(entity)
        } else {
            this.idDuplicates[id] = [entity]
        }
        this.byId[id] = entity
    }

    _addByNameWithDuplicate(key: string, entity: SlackEntity) {
        if (isDefined(this.byName[key])) {
            this.nameDuplicates[key].push(entity)
        } else {
            this.nameDuplicates[key] = [entity]
        }
        this.byName[key] = entity
    }

    idDuplicateCount(id: string) {
        id = id.toUpperCase()
        const l = this.idDuplicates[id]
        if (isDefined(l)) {
            return l.length
        }
        return 0
    }

    getIdDuplicates(id: string): SlackEntity[] {
        id = id.toUpperCase()
        const l = this.idDuplicates[id]
        if (isDefined(l)) {
            return l
        }
        return []
    }

    add(entity: SlackEntity) {
        this._addByIdWithDuplicate(entity.id.toUpperCase(), entity)
        if (entity.name === undefined) {
            this.log.warn(`${entity.id} does not have a name`)
            return
        }
        const slackName = entity.name.toLowerCase()
        this._addByNameWithDuplicate(slackName, entity)
        if (entity.lichess_username) {
            const lichessId = entity.lichess_username.toLowerCase()
            this._addByNameWithDuplicate(lichessId, entity)
        }
    }

    getId(name: string): string | undefined {
        const entity = this.byName[name.toLowerCase()]
        if (!entity) {
            this.log.error("Couldn't find entity by name: " + name)
            return undefined
        }
        return entity.id
    }

    getName(id: string): string | undefined {
        const entity = this.byId[id]
        if (!entity) {
            this.log.error("Couldn't find entity by id: " + id)
            return undefined
        }
        return entity.name
    }

    getIdString(name: string): string | undefined {
        const id = this.getId(name)
        return `<${this.idStringPrefix}${id}>`
    }

    getByNameOrID(nameOrId: string): SlackEntity | undefined {
        return (
            this.byId[nameOrId.toUpperCase()] ||
            this.byName[nameOrId.toLowerCase()]
        )
    }
}

export class SlackBot {
    private log: LogWithPrefix
    public config: config.ChessterConfig
    private token: string
    public users: SlackEntityLookup<LeagueMember>
    public channels: SlackEntityLookup<SlackChannel>
    public rtm: RTMClient
    public web: WebClient
    public controller?: SlackBotSelf
    private team?: SlackTeam
    private refreshCount = 0
    private listeners: SlackRTMEventListenerOptions[] = []

    constructor(
        public slackName: string,
        public configFile = './config/config.js',
        public debug = false,
        public connectToModels = true,
        public refreshLeagues = true,
        public logToThisSlack = false
    ) {
        this.log = new LogWithPrefix(`[SlackBot: ${this.slackName}]`)
        this.log.info(`Loading config from: ${this.configFile}`)
        this.config = config.ChessterConfigDecoder.decodeJSON(
            JSON.stringify(require(this.configFile))
        )
        this.token = this.config.slackTokens[this.slackName]

        if (!this.token) {
            const error = `Failed to load token for ${this.slackName} from ${this.configFile}`
            this.log.error(error)
            throw new Error(error)
        }
        this.users = new SlackEntityLookup<LeagueMember>(
            slackName,
            'Users',
            '@'
        )
        this.channels = new SlackEntityLookup<SlackChannel>(
            slackName,
            'Channels',
            '#'
        )

        this.rtm = new RTMClient(this.token)
        this.web = new WebClient(this.token)
    }
    async start() {
        // Connect to Slack
        const { self, team } = await this.rtm.start()
        this.controller = self as SlackBotSelf
        this.team = team as SlackTeam

        // Listen to events that we need
        // https://api.slack.com/events
        this.rtm.on('goodbye', () => {
            // TODO: figure out how to better handle this
            this.log.error(
                'RTM connection closing unexpectedly. I am going down.'
            )
            process.exit(1)
        })

        // -----------------------------------------------------------------------------
        // Log lifecycle events
        this.rtm.on('connecting', () => {
            this.log.info('Connecting')
        })
        this.rtm.on('authenticated', (connectData) => {
            this.log.info(`Authenticating: ${JSON.stringify(connectData)}`)
        })
        this.rtm.on('connected', () => {
            this.log.info('Connected')
        })
        this.rtm.on('ready', () => {
            this.log.info('Ready')
        })
        this.rtm.on('disconnecting', () => {
            this.log.info('Disconnecting')
        })
        this.rtm.on('reconnecting', () => {
            this.log.info('Reconnecting')
        })
        this.rtm.on('disconnected', (error) => {
            this.log.error(`Disconnecting: ${JSON.stringify(error)}`)
        })
        this.rtm.on('error', (error) => {
            this.log.error(`Error: ${JSON.stringify(error)}`)
        })
        this.rtm.on('unable_to_rtm_start', (error) => {
            this.log.error(`Unable to RTM start: ${JSON.stringify(error)}`)
        })

        // connect to the database
        if (this.connectToModels) {
            await models.connect(this.config)
        }

        this.startOnListener()

        // refresh your user and channel list every 10 minutes.
        // used to be every 2 minutes but we started to hit rate limits.
        // would be nice if this was push model, not poll but oh well.
        await this.refresh(600 * SECONDS)

        if (this.logToThisSlack) {
            // setup logging
            // Pass in a reference to ourselves.
            winston.info('adding slack logger')
            winston.add(
                new SlackLogger(
                    this,
                    this.config.winston.channel,
                    this.config.winston.username,
                    this.config.winston.level,
                    this.config.winston.handleExceptions
                )
            )
        }
    }

    async updatesUsers() {
        // Get our lichess username to slack username map
        const slackIDByLichessUsername = await heltour.getUserMap(
            this.config.heltour
        )
        if (this.controller) {
            slackIDByLichessUsername.chesster = this.controller.id
        }
        const lichessUsernameBySlackID: Record<SlackUserID, SlackUserName> = {}
        _.forOwn(slackIDByLichessUsername, (slackID, lichessUsername) => {
            lichessUsernameBySlackID[slackID] = lichessUsername.toLowerCase()
        })

        const newUsers = new SlackEntityLookup<LeagueMember>(
            this.users.slackName,
            this.users.typePostfix,
            this.users.idStringPrefix
        )

        // TODO: follow this bug report: https://github.com/slackapi/node-slack-sdk/issues/779
        // @ https://api.slack.com/methods/users.list
        //
        // Iterate over all of the slack users and map them up
        for await (const page of (this.web.paginate(
            'users.list'
        ) as unknown) as AsyncIterable<SlackUserListResponse>) {
            if (page.ok) {
                page.members.map((slackUser) => {
                    newUsers.add({
                        ...slackUser,
                        lichess_username:
                            lichessUsernameBySlackID[slackUser.id],
                    })
                })
            }
        }
        _.forOwn(slackIDByLichessUsername, (slackID, lichessUsername) => {
            const slackUser = newUsers.getByNameOrID(slackID)
            if (!slackUser) {
                return
            }
            newUsers.add({
                ...slackUser,
                lichess_username: lichessUsername.toLowerCase(),
            })
        })
        this.log.info('Updating Users')
        this.users = newUsers
    }

    async updateChannels() {
        const newChannels = new SlackEntityLookup<SlackChannel>(
            this.channels.slackName,
            this.channels.typePostfix,
            this.channels.idStringPrefix
        )
        const newMPIMs = new SlackEntityLookup<SlackChannel>(
            this.channels.slackName,
            this.channels.typePostfix,
            this.channels.idStringPrefix
        )
        // @ https://api.slack.com/methods/conversations.list
        for await (const page of (this.web.paginate('conversations.list', {
            types: 'public_channel,private_channel',
            exclude_archived: true,
        }) as unknown) as AsyncIterable<SlackChannelListResponse>) {
            if (page.ok) {
                page.channels.map((c) => {
                    if (c.is_channel) newChannels.add(c)
                })
            }
        }
        this.channels = newChannels
    }

    async getChannelMemberList(
        channel: SlackChannel
    ): Promise<SlackChannelMembersList> {
        const response = (await this.web.conversations.members({
            channel: channel.id,
        })) as SlackChannelMembersList
        return response
    }

    getLeagueMemberTarget(message: CommandMessage): LeagueMember | undefined {
        if (!isDefined(message.member)) {
            return
        }
        // The user is either a string or an id
        let nameOrId = message.member.id

        if (message.matches[1]) {
            nameOrId = message.matches[1]
        }
        const player = this.getSlackUserFromNameOrID(nameOrId)

        return player
    }

    getSlackUserFromNameOrID(nameOrId: string): LeagueMember | undefined {
        const self = this

        // The name or Id was provided, so parse it out
        const player = self.users.getByNameOrID(nameOrId)

        // If the player didn't exist that way, then it could be the @notation
        if (!player) {
            // Slack user ids are tranlated in messages to something like <@U17832>.  This
            // regex will capture the U17832 part so we can send it through the getByNameOrId
            // function
            const userIdExtraction = nameOrId.match(slackIDRegex)
            if (userIdExtraction) {
                return self.users.getByNameOrID(userIdExtraction[1])
            } else {
                return self.users.getByNameOrID(nameOrId.toLowerCase())
            }
        }
        return player
    }
    async refresh(delay: number) {
        return criticalPath(
            new Promise((resolve) => {
                this.refreshCount++
                this.log.info(`doing refresh ${this.refreshCount}`)

                this.updatesUsers()
                this.updateChannels()
                if (this.refreshLeagues) {
                    this.log.info('Refreshing Leagues')
                    league.getAllLeagues(this).map((l) => l.refresh())
                    setTimeout(() => {
                        this.refresh(delay)
                    }, delay)
                }
                resolve()
            })
        )
    }

    async hasSingleUser(message: CommandMessage) {
        const usernames = Array.from(
            new Set<string>(
                this.users
                    .getIdDuplicates(message.user)
                    .map((u) => u.lichess_username)
            )
        )
        const c = usernames.length
        if (c === 1) {
            return true
        } else {
            await this.reply(
                message,
                `This command requires you to have a single lichess account associated with your slack account. you have ${c}:
${usernames.join(', ')}`
            )
            return false
        }
    }

    async startPrivateConversation(
        nameOrId: string[]
    ): Promise<SlackConversation> {
        const targetUsers = nameOrId
            .map((u) => this.getSlackUserFromNameOrID(u))
            .filter(isDefined)
            .map((u) => u.id)
            .join(',')
        if (_.isNil(targetUsers)) {
            throw new Error('Unable to find user')
        } else {
            return (await this.web.conversations.open({
                users: targetUsers,
            })) as SlackConversation
        }
    }

    async reply(message: ChessterMessage, response: string) {
        if (!message.channel) return
        return this.say({
            channel: message.channel.id,
            text: response,
        })
    }

    async say(options: ChatPostMessageArguments) {
        // Replace user links in the form <@user> with <@U12345|user>
        if (options.text) {
            options.text = options.text.replace(
                /<\@([\w-\.]+)>/g,
                (match: string, username: string) => {
                    const user = this.users.getByNameOrID(username)
                    if (user) {
                        return '<@' + user.id + '|' + user.name + '>'
                    }
                    return match
                }
            )
        }
        options.as_user = true
        return this.web.chat.postMessage(options)
    }

    async getChannel(channelId: string): Promise<SlackChannel | undefined> {
        let channel = this.channels.getByNameOrID(channelId)
        if (channel) return channel
        const channelResponse = (await this.web.conversations.info({
            channel: channelId,
        })) as SlackChannelInfoResponse
        if (channelResponse.ok) {
            channel = channelResponse.channel
            this.channels.add(channel)
            return channel
        }
        return undefined
    }

    async getUser(userId: string): Promise<SlackUser | undefined> {
        let user: SlackUser | undefined = this.users.getByNameOrID(userId)
        if (user) return user
        const userResponse = (await this.web.users.info({
            user: userId,
        })) as SlackUserInfoResponse
        if (userResponse.ok) {
            user = userResponse.user
            return user
        }
        return undefined
    }

    async react(message: CommandMessage, emoji: string) {
        if (!message.ts) return
        return this.web.reactions.add({
            channel: message.channel.id,
            name: emoji,
            timestamp: message.ts.toString(),
        })
    }

    async handleMatch(
        listener: SlackRTMEventListenerOptions,
        message: CommandMessage
    ) {
        let allowedTypes = ['command', 'league_command']
        let member: LeagueMember | undefined
        if (message.user) {
            member = this.users.getByNameOrID(message.user)
        }
        const _league = getLeague(this, message, false)
        if (!_league || !member) {
            allowedTypes = ['command']
        }
        message.isPingModerator =
            message.channel.id in this.config.pingMods &&
            !!member &&
            this.config.pingMods[message.channel.id].includes(member.id)

        try {
            try {
                if (
                    listener.type === 'command' &&
                    allowedTypes.indexOf('command') !== -1
                ) {
                    _.map(listener.middleware, (m) =>
                        m(this, { member, ...message })
                    )
                    listener.callback(this, { member, ...message })
                } else if (
                    listener.type === 'league_command' &&
                    allowedTypes.indexOf('league_command') !== -1
                ) {
                    if (_league && member) {
                        // Typescript should have been able know that they are set appropriately
                        // here.
                        _.map(listener.middleware, (m) => m(this, message))
                        const leagueCommandMessage: LeagueCommandMessage = {
                            ...message,
                            league: _league,
                            member,
                            isModerator: _league.isModerator(
                                member.lichess_username
                            ),
                        }
                        listener.callback(this, leagueCommandMessage)
                    } else {
                        this.log.warn('Typescript failed me')
                    }
                }
            } catch (error) {
                if (error instanceof StopControllerError) {
                    this.log.error(
                        `Middleware asked to not process controller callback: ${JSON.stringify(
                            error
                        )}`
                    )
                }
            }
        } catch (e) {
            this.log.info(`Error handling event: ${message}`)
            this.say({
                channel: message.channel.id,
                text:
                    'Something has gone terribly terribly wrong. Please forgive me.',
            })
        }
    }

    async startOnListener() {
        this.rtm.on('message', async (event: SlackMessage) => {
            try {
                const channel = await this.getChannel(event.channel)
                if (!channel) {
                    this.log.warn(
                        `Unable to get details for channel: ${event.channel}`
                    )
                    return
                }
                const chessterMessage: ChessterMessage = {
                    ...event,
                    type: 'message',
                    channel,
                    isPingModerator: false,
                }

                const isBotMessage =
                    event.subtype === 'bot_message' || event.bot_id
                const isDirectMessage =
                    channel &&
                    channel.is_im &&
                    !channel.is_group &&
                    !isBotMessage
                const isDirectMention =
                    chessterMessage.text.indexOf(
                        `<@${this.controller?.id}>`
                    ) !== -1 && !isBotMessage
                const isAmbient = !(
                    isDirectMention ||
                    isDirectMessage ||
                    isBotMessage
                )
                this.listeners.map(async (listener) => {
                    let isWanted = false
                    let text = event.text
                    if (isDirectMessage && wantsDirectMessage(listener)) {
                        isWanted = true
                    } else if (
                        isDirectMention &&
                        wantsDirectMention(listener)
                    ) {
                        isWanted = true
                        text = text.replace(`<@${this.controller?.id}> `, '')
                        text = text.replace(`<@${this.controller?.id}>`, '')
                    } else if (isAmbient && wantsAmbient(listener)) {
                        isWanted = true
                    } else if (isBotMessage && wantsBotMessage(listener)) {
                        isWanted = true
                    }

                    if (!isWanted) return

                    listener.patterns.some((p) => {
                        const matches = text.match(p)
                        if (!matches) return false
                        this.handleMatch(listener, {
                            ...chessterMessage,
                            text: text.trim(),
                            matches,
                        })
                        return true
                    })
                })
            } catch (error) {
                this.log.error(
                    `Uncaught error in handling an rtm message: ${JSON.stringify(
                        error
                    )}`
                )
                this.log.error(`Stack: ${new Error().stack}`)
            }
        })
    }

    hears(options: SlackRTMEventListenerOptions): void {
        this.listeners.push(options)
    }

    on(options: OnOptions) {
        return this.rtm.on(options.event, (event) =>
            options.callback(this, event)
        )
    }
}