alexandre-normand/slackscot

View on GitHub
plugins/karma.go

Summary

Maintainability
A
35 mins
Test Coverage
A
97%
package plugins

import (
    "fmt"
    "github.com/alexandre-normand/slackscot"
    "github.com/alexandre-normand/slackscot/actions"
    "github.com/alexandre-normand/slackscot/plugin"
    "github.com/alexandre-normand/slackscot/store"
    "github.com/slack-go/slack"
    "log"
    "regexp"
    "sort"
    "strconv"
    "strings"
)

// Karma holds the plugin data for the karma plugin
type Karma struct {
    *slackscot.Plugin
    karmaStorer store.GlobalSiloStringStorer
}

const (
    // KarmaPluginName holds identifying name for the karma plugin
    KarmaPluginName  = "karma"
    defaultItemCount = 5
)

var karmaRegex = regexp.MustCompile("(?:(<(@[\\w']+)>\\s?))(\\+{2,6}|\\-{2,6})")

// Ranker represents attributes and behavior to process a ranking list
type ranker struct {
    name             string
    regexp           *regexp.Regexp
    bannerText       string
    bannerImgLink    string
    bannerImgAltText string
    scanner          karmaScanner
    sorter           karmaSorter
}

var globalTopRanker ranker
var topRanker ranker
var globalWorstRanker ranker
var worstRanker ranker

func init() {
    globalTopRanker = ranker{name: "global top",
        regexp:     regexp.MustCompile("(?i)\\A(global top)+(?:\\s+(\\d*))*\\z"),
        bannerText: ":leaves::leaves::leaves::trophy: *Global Top* :trophy::leaves::leaves::leaves:",
        scanner:    scanGlobalKarma,
        sorter:     sortTop}

    topRanker = ranker{name: "top",
        regexp:     regexp.MustCompile("(?i)\\A(top)+(?:\\s+(\\d*))*\\z"),
        bannerText: ":leaves::leaves::leaves::trophy: *Top* :trophy::leaves::leaves::leaves:",
        scanner:    scanChannelKarma,
        sorter:     sortTop}

    globalWorstRanker = ranker{name: "global worst",
        regexp:     regexp.MustCompile("(?i)\\A(global worst)+(?:\\s+(\\d*))*\\z"),
        bannerText: ":fallen_leaf::fallen_leaf::fallen_leaf::space_invader: *Global Worst* :space_invader::fallen_leaf::fallen_leaf::fallen_leaf:",
        scanner:    scanGlobalKarma,
        sorter:     sortWorst}

    worstRanker = ranker{name: "worst",
        regexp:     regexp.MustCompile("(?i)\\A(worst)+(?:\\s+(\\d*))*\\z"),
        bannerText: ":fallen_leaf::fallen_leaf::fallen_leaf::space_invader: *Worst* :space_invader::fallen_leaf::fallen_leaf::fallen_leaf:",
        scanner:    scanChannelKarma,
        sorter:     sortWorst}
}

// NewKarma creates a new instance of the Karma plugin
func NewKarma(storer store.GlobalSiloStringStorer) (karma *slackscot.Plugin) {
    k := new(Karma)

    k.Plugin = plugin.New(KarmaPluginName).
        WithCommandNamespacing().
        WithCommand(actions.NewCommand().
            WithMatcher(matchKarmaTopReport).
            WithUsage("top [count]").
            WithDescriptionf("Return the top things ever recorded in this channel (default of %d items)", defaultItemCount).
            WithAnswerer(k.answerKarmaTop).
            Build()).
        WithCommand(actions.NewCommand().
            WithMatcher(matchKarmaWorstReport).
            WithUsage("worst [count]").
            WithDescriptionf("Return the worst things ever recorded in this channel (default of %d items)", defaultItemCount).
            WithAnswerer(k.answerKarmaWorst).
            Build()).
        WithCommand(actions.NewCommand().
            WithMatcher(matchGlobalKarmaTopReport).
            WithUsage("global top [count]").
            WithDescriptionf("Return the top things ever over all channels (default of %d items)", defaultItemCount).
            WithAnswerer(k.answerGlobalKarmaTop).
            Build()).
        WithCommand(actions.NewCommand().
            WithMatcher(matchGlobalKarmaWorstReport).
            WithUsage("global worst [count]").
            WithDescriptionf("Return the worst things ever over all channels (default of %d items)", defaultItemCount).
            WithAnswerer(k.answerGlobalKarmaWorst).
            Build()).
        WithCommand(actions.NewCommand().
            Hidden().
            WithMatcher(matchKarmaReset).
            WithUsage("reset").
            WithDescription("Resets all recorded karma for the current channel").
            WithAnswerer(k.clearChannelKarma).
            Build()).
        WithHearAction(actions.NewCommand().
            WithMatcher(matchKarmaRecord).
            WithUsage("thing++ or thing--").
            WithDescription("Keep track of karma. Increments larger than `1` (up to `5`) can be achieved with extra `+` or `-` signs").
            WithAnswerer(k.recordKarma).
            Build()).
        Build()

    k.karmaStorer = storer

    return k.Plugin
}

// matchKarmaRecord returns true if the message matches karma++ or karma-- (karma being any word)
func matchKarmaRecord(m *slackscot.IncomingMessage) bool {
    matches := karmaRegex.FindStringSubmatch(m.NormalizedText)
    return len(matches) > 0
}

// matchKarmaTopReport returns true if the message matches a request for top karma with
// a message such as "top <count>"
func matchKarmaTopReport(m *slackscot.IncomingMessage) bool {
    return topRanker.regexp.MatchString(m.NormalizedText)
}

// matchKarmaWorstReport returns true if the message matches a request for the worst karma with
// a message such as "worst <count>"
func matchKarmaWorstReport(m *slackscot.IncomingMessage) bool {
    return worstRanker.regexp.MatchString(m.NormalizedText)
}

// matchGlobalKarmaTopReport returns true if the message matches a request for top global karma with
// a message such as "global top <count>"
func matchGlobalKarmaTopReport(m *slackscot.IncomingMessage) bool {
    return globalTopRanker.regexp.MatchString(m.NormalizedText)
}

// matchGlobalKarmaWorstReport returns true if the message matches a request for the worst global karma with
// a message such as "global worst <count>"
func matchGlobalKarmaWorstReport(m *slackscot.IncomingMessage) bool {
    return globalWorstRanker.regexp.MatchString(m.NormalizedText)
}

// matchKarmaReset returns true if the message matches a request for resetting karma with a
// message such as "reset"
func matchKarmaReset(m *slackscot.IncomingMessage) bool {
    return strings.HasPrefix(m.NormalizedText, "reset")
}

// recordKarma records a karma increase or decrease and answers with a message including
// the recorded word with its associated karma value
func (k *Karma) recordKarma(message *slackscot.IncomingMessage) *slackscot.Answer {
    matches := karmaRegex.FindAllStringSubmatch(message.Text, -1)
    answerText := ""
    
    for idx, match := range matches {
        
        // only add newlines if more than one match
        if idx > 0 {
            answerText += "\n"
        }
        
        thing := match[2]
        // Prevent a user from attributing karma to self
        if strings.TrimPrefix(thing, "@") == message.User {
            return &slackscot.Answer{Text: "*Attributing yourself karma is frown upon* :face_with_raised_eyebrow:", Options: []slackscot.AnswerOption{slackscot.AnswerEphemeral(message.User)}}
        }

        rawValue, err := k.karmaStorer.GetSiloString(message.Channel, thing)
        if err != nil {
            rawValue = "0"
        }
        karma, err := strconv.Atoi(rawValue)
        if err != nil {
            k.Logger.Printf("[%s] Error parsing current karma value [%s], something's wrong and resetting to 0: %v", KarmaPluginName, rawValue, err)
            karma = 0
        }

        log.Printf("thing is [%s]\n", thing)
        renderedThing := k.renderThing(thing)

        instruction := match[3]
        if strings.HasPrefix(instruction, "+") {
            incrementSymbols := strings.TrimPrefix(instruction, "+")
            increment := len(incrementSymbols)
            karma = karma + increment

            if increment == 1 {
                answerText += fmt.Sprintf("`%s` just gained karma (`%s`: %d)", renderedThing, renderedThing, karma)
            } else {
                answerText += fmt.Sprintf("`%s` just gained %d karma points (`%s`: %d)", renderedThing, increment, renderedThing, karma)
            }

        } else {
            decrementSymbols := strings.TrimPrefix(instruction, "-")
            decrement := len(decrementSymbols)
            karma = karma - decrement

            if decrement == 1 {
                answerText += fmt.Sprintf("`%s` just lost karma (`%s`: %d)", renderedThing, renderedThing, karma)
            } else {
                answerText += fmt.Sprintf("`%s` just lost %d karma points (`%s`: %d)", renderedThing, decrement, renderedThing, karma)
            }
        }

        // Store new value
        err = k.karmaStorer.PutSiloString(message.Channel, thing, strconv.Itoa(karma))
        if err != nil {
            k.Logger.Printf("[%s] Error persisting karma: %v", KarmaPluginName, err)
            return nil
        }
    }



    return &slackscot.Answer{Text: answerText}
}

// renderThing renders the thing value. In most cases, it should just return the value
// untouched but if it starts with '@', it tries to find the user info matching the value
// and returns that instead (if found a match)
func (k *Karma) renderThing(thing string) (renderedThing string) {
    if strings.HasPrefix(thing, "@") {
        u, _ := k.UserInfoFinder.GetUserInfo(strings.TrimPrefix(thing, "@"))

        if u != nil {
            return u.RealName
        }
    }

    return thing
}

// answerKarmaTop returns an answer with the top list of karma entries for the channel the message is received on
func (k *Karma) answerKarmaTop(m *slackscot.IncomingMessage) *slackscot.Answer {
    return k.answerKarmaRankList(m, topRanker)
}

// answerKarmaTop returns an answer with the list of worst karma entries for the channel the message is received on
func (k *Karma) answerKarmaWorst(m *slackscot.IncomingMessage) *slackscot.Answer {
    return k.answerKarmaRankList(m, worstRanker)
}

// answerKarmaTop returns an answer with the top list of karma entries for all channels
func (k *Karma) answerGlobalKarmaTop(m *slackscot.IncomingMessage) *slackscot.Answer {
    return k.answerKarmaRankList(m, globalTopRanker)
}

// answerKarmaTop returns an answer with the list of worst karma entries for all channels
func (k *Karma) answerGlobalKarmaWorst(m *slackscot.IncomingMessage) *slackscot.Answer {
    return k.answerKarmaRankList(m, globalWorstRanker)
}

// clearChannelKarma processes a request to clear karma in a channel (the message's channel is used to tell which one)
func (k *Karma) clearChannelKarma(m *slackscot.IncomingMessage) *slackscot.Answer {
    entries, err := k.karmaStorer.ScanSilo(m.Channel)
    if err != nil {
        return &slackscot.Answer{Text: fmt.Sprintf("Sorry, I couldn't get delete karma for channel [%s] for you. If you must know, this happened: %s", m.Channel, err.Error())}
    }

    for thing := range entries {
        err = k.karmaStorer.DeleteSiloString(m.Channel, thing)
    }

    if err != nil {
        return &slackscot.Answer{Text: fmt.Sprintf("Sorry, I couldn't get delete karma for channel [%s] for you. If you must know, this happened: %s", m.Channel, err.Error())}
    }

    return &slackscot.Answer{Text: "karma all cleared :white_check_mark::boom:"}
}

// karmaSorter is a function sorting pairList of karma entries. Used to plug in top/worst sorting
type karmaSorter func(pl pairList)

// sortWorst sorts karma from the lowest value to the highest
func sortWorst(pl pairList) {
    sort.Sort(pl)
}

// sortWorst sorts karma from the highest to lowest
func sortTop(pl pairList) {
    sort.Sort(sort.Reverse(pl))
}

// karmaScanner is a function that returns karma entries for a given channel. It is used
// to plug in different behaviors like channel scanning and global scanning
type karmaScanner func(karmaStorer store.GlobalSiloStringStorer, channelID string) (entries map[string]string, err error)

// scanChannelKarma scans the silo for the given channel id and returns only the entries for that
// channel
func scanChannelKarma(karmaStorer store.GlobalSiloStringStorer, channelID string) (entries map[string]string, err error) {
    return karmaStorer.ScanSilo(channelID)
}

// scanGlobalKarma invokes a GlobalScan and merges karma over all channels. If there's
// an error, a nil map is returned along with that error
func scanGlobalKarma(karmaStorer store.GlobalSiloStringStorer, channelID string) (entries map[string]string, err error) {
    entriesByChannel, err := karmaStorer.GlobalScan()
    if err != nil {
        return nil, err
    }

    entries = make(map[string]string)
    for _, chEntries := range entriesByChannel {
        for thing, val := range chEntries {
            if _, ok := entries[thing]; !ok {
                entries[thing] = val
            } else {
                entries[thing], err = mergeKarma(entries[thing], val)
                if err != nil {
                    return nil, err
                }
            }
        }
    }

    return entries, nil
}

// mergeKarma merges two values assumed to be strings holding integers and
// returns the sum as a string
func mergeKarma(v1 string, v2 string) (merged string, err error) {
    val1, err := strconv.Atoi(v1)
    if err != nil {
        return "", err
    }

    val2, err := strconv.Atoi(v2)
    if err != nil {
        return "", err
    }

    return strconv.Itoa(val1 + val2), nil
}

// answerKarmaRankList returns an answer for a ranked list request according to the behavior and attributes of the given ranker
func (k *Karma) answerKarmaRankList(m *slackscot.IncomingMessage, ranker ranker) *slackscot.Answer {
    match := ranker.regexp.FindAllStringSubmatch(m.NormalizedText, -1)[0]

    count := defaultItemCount
    rawCount := match[2]
    if len(rawCount) > 0 {
        count, _ = strconv.Atoi(rawCount)
    }

    values, err := ranker.scanner(k.karmaStorer, m.Channel)
    if err != nil {
        return &slackscot.Answer{Text: fmt.Sprintf("Sorry, I couldn't get the %s [%d] things for you. If you must know, this happened: %v", ranker.name, count, err)}
    }

    pairs, err := getRankedList(values, count, ranker.sorter)
    if err != nil {
        return &slackscot.Answer{Text: fmt.Sprintf("Sorry, I couldn't get the %s [%d] things for you. If you must know, this happened: %v", ranker.name, count, err)}
    }

    if len(pairs) > 0 {
        blocks := make([]slack.Block, 0)

        blocks = append(blocks, slack.NewSectionBlock(slack.NewTextBlockObject("mrkdwn", ranker.bannerText, false, false), nil, nil))
        blocks = append(blocks, k.formatList(pairs)...)

        return &slackscot.Answer{Text: "", ContentBlocks: blocks}
    }

    return &slackscot.Answer{Text: "Sorry, no recorded karma found :disappointed:"}
}

// formatList formats a list of ranked items using the rankRenderer to render the rank icons and returns the resulting block kit blocks
func (k *Karma) formatList(pl pairList) (blocks []slack.Block) {
    blocks = make([]slack.Block, 0)

    rank := 1
    for _, pair := range pl {
        blocks = append(blocks, formatRankedElement(pair, rank))
        rank = rank + 1
    }

    return blocks
}

// formatRankedElement formats one ranked element in a list. It adds 3 blocks: one for the rank (icon),
// one for the ranked "thing" and one for its karma value. The 3 block objects are then wrapped in a context block
func formatRankedElement(p pair, rank int) (block slack.Block) {
    return *slack.NewSectionBlock(slack.NewTextBlockObject("mrkdwn", fmt.Sprintf("• %s `%d`", renderThingName(p.Key), p.Value), false, false), nil, nil)
}

// renderThingName renders a karma item by formatting a user id with the required symbols such that it looks
// like <@userId>. For things that aren't user ids, the value is returned as-is
func renderThingName(thing string) (render string) {
    if strings.HasPrefix(thing, "@") {
        return "<" + thing + ">"
    }

    return thing
}

// pair holds a key (thing name) and its count
type pair struct {
    Key   string
    Value int
}

// pairList adapted from Andrew Gerrand for a similar problem: https://groups.google.com/forum/#!topic/golang-nuts/FT7cjmcL7gw
type pairList []pair

func (p pairList) Len() int { return len(p) }

func (p pairList) Less(i, j int) bool {
    return p[i].Value < p[j].Value || (p[i].Value == p[j].Value && strings.Compare(p[i].Key, p[j].Key) > 0)
}

func (p pairList) Swap(i, j int) { p[i], p[j] = p[j], p[i] }

func convertToPairs(wordFrequencies map[string]int) pairList {
    pl := make(pairList, len(wordFrequencies))
    i := 0
    for k, v := range wordFrequencies {
        pl[i] = pair{k, v}
        i++
    }

    return pl
}

func getRankedList(rawData map[string]string, count int, sort karmaSorter) (results pairList, err error) {
    wordWithFrequencies, err := convertMapValues(rawData)
    if err != nil {
        return results, err
    }

    pl := convertToPairs(wordWithFrequencies)

    sort(pl)

    limit := count

    if len(pl) < count {
        limit = len(pl)
    }
    return pl[:limit], nil
}

func convertMapValues(rawData map[string]string) (result map[string]int, err error) {
    result = map[string]int{}

    for k, v := range rawData {
        result[k], err = strconv.Atoi(v)
        if err != nil {
            return result, err
        }
    }

    return result, nil
}