alexandre-normand/slackscot

View on GitHub
plugins/triggerer.go

Summary

Maintainability
A
0 mins
Test Coverage
A
97%
package plugins

import (
    "bufio"
    "bytes"
    "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"
    "regexp"
    "sort"
    "strings"
    "text/tabwriter"
)

// Triggerer holds the plugin data for the triggerer plugin
// The triggerer plugin consists of a command to register a new trigger with its answer along
// with a hear action that will listen and react if one of the registered triggers is said
type Triggerer struct {
    *slackscot.Plugin
    triggerStorer  store.GlobalSiloStringStorer
    triggerRegexes map[string]*regexp.Regexp
}

const (
    // TriggererPluginName holds identifying name for the triggerer plugin
    TriggererPluginName = "triggerer"
    emojiDelimiter      = ","
    globalSiloName      = ""
)

// Trigger types
const (
    emojiTriggerTypeID    = 'E'
    standardTriggerTypeID = 'S'
)

// triggerType represents a trigger type and holds attributes that
// define it
type triggerType struct {
    ID               rune
    Name             string
    SlackRender      elementRenderer
    ReactionEncoder  reactionEncoder
    ReactionRenderer reactionRenderer
    RegisterRegex    *regexp.Regexp
    DeleteRegex      *regexp.Regexp
}

// elementRenderer is a function that takes in a trigger value and renders it to be included as a line in a table
// for the listTriggers rendering
type elementRenderer func(trigger string, reaction string) (tableRender string)

// renderEmojiTrigger renders an emoji trigger/reaction to be included in a listTriggers output
func renderEmojiTrigger(trigger string, reaction string) (rendered string) {
    return fmt.Sprintf("`%s`\t=> %s", trigger, renderSlackEmojis(parseEmojiList(reaction)))
}

// renderStandardTrigger renders a standard trigger/reaction to be included in a listTriggers output
func renderStandardTrigger(trigger string, reaction string) (rendered string) {
    return fmt.Sprintf("`%s`\t=> %s", trigger, renderStandardReaction(reaction))
}

// reactionEncoder is a function that takes in a raw reaction string and encodes it as a string to be persisted
type reactionEncoder func(rawReaction string) (encodedReaction string, err error)

// encodeStandardReaction encodes the rawReaction by just returning the value unchanged (no extra processing necessary)
func encodeStandardReaction(rawReaction string) (encodedReaction string, err error) {
    return rawReaction, nil
}

// encodeEmojiReaction encodes the rawReaction for an emoji trigger by parsing out emoji names and
// rendering them as a comma-delimited list. If no emojis are found in the rawReaction, an error is returned
func encodeEmojiReaction(rawReaction string) (encodedReaction string, err error) {
    emojis := parseAllEmojis(rawReaction)

    if len(emojis) == 0 {
        return "", fmt.Errorf("`<reaction emojis>` doesn't include any emojis")
    }

    return encodeEmojis(emojis), nil
}

// reactionRenderer is a function that takes in an encoded reaction and renders it for slack output
type reactionRenderer func(encodedReaction string) (slackRender string)

// encodeStandardReaction encodes the standard encodedReaction by wrapping it with backticks except
// when the reaction already includes at least one. In which case, the reaction is returned as-is
func renderStandardReaction(encodedReaction string) (slackRender string) {
    if strings.Contains(encodedReaction, "`") {
        return encodedReaction
    }

    return fmt.Sprintf("`%s`", encodedReaction)
}

// renderEmojiReaction encodes the encodedReaction by wrapping each value with colons so that slack renders emojis properly
func renderEmojiReaction(encodedReaction string) (slackRender string) {
    emojis := parseEmojiList(encodedReaction)
    return renderSlackEmojis(emojis)
}

var triggerTypes map[rune]triggerType
var emojiRegex = regexp.MustCompile(":([\\w_-]+):")

func init() {
    registerTriggerRegex := regexp.MustCompile("(?msi)\\Atrigger (anywhere )?on (.+) with (.+)")
    deleteTriggerRegex := regexp.MustCompile("(?i)\\Aforget trigger on (.+)")

    registerEmojiTriggerRegex := regexp.MustCompile("(?i)\\Aemoji trigger (anywhere )?on (.+) with (.+)")
    deleteEmojiTriggerRegex := regexp.MustCompile("(?i)\\Aforget emoji trigger on (.+)")

    triggerTypes = make(map[rune]triggerType)
    triggerTypes[emojiTriggerTypeID] = triggerType{ID: emojiTriggerTypeID, Name: "emoji", SlackRender: renderEmojiTrigger, ReactionEncoder: encodeEmojiReaction, ReactionRenderer: renderEmojiReaction, RegisterRegex: registerEmojiTriggerRegex, DeleteRegex: deleteEmojiTriggerRegex}
    triggerTypes[standardTriggerTypeID] = triggerType{ID: standardTriggerTypeID, Name: "standard", SlackRender: renderStandardTrigger, ReactionEncoder: encodeStandardReaction, ReactionRenderer: renderStandardReaction, RegisterRegex: registerTriggerRegex, DeleteRegex: deleteTriggerRegex}
}

// NewTriggerer creates a new instance of the Triggerer plugin
func NewTriggerer(storer store.GlobalSiloStringStorer) (p *slackscot.Plugin) {
    t := new(Triggerer)
    t.triggerStorer = storer
    t.triggerRegexes = make(map[string]*regexp.Regexp)

    t.Plugin = plugin.New(TriggererPluginName).
        WithHearAction(actions.NewHearAction().
            Hidden().
            WithMatcher(t.matchTriggers).
            WithUsage("say something that includes the trigger").
            WithDescription("Stays alert to react on registered triggers and react accordingly").
            WithAnswerer(t.reactOnTriggers).
            Build(),
        ).
        WithCommand(actions.NewCommand().
            WithMatcher(matchNewStandardTrigger).
            WithUsage("trigger [anywhere] on <trigger string> with <reaction string>").
            WithDescription("Register a trigger which will instruct me to react with `reaction string` when someone says `trigger string`").
            WithAnswerer(t.registerStandardTrigger).
            Build(),
        ).
        WithCommand(actions.NewCommand().
            WithMatcher(matchDeleteStandardTrigger).
            WithUsage("forget trigger on <trigger string>").
            WithDescription("Delete a trigger on `trigger string`").
            WithAnswerer(t.deleteStandardTrigger).
            Build(),
        ).
        WithCommand(actions.NewCommand().
            WithMatcher(func(m *slackscot.IncomingMessage) bool { return strings.HasPrefix(m.NormalizedText, "list triggers") }).
            WithUsage("list triggers").
            WithDescription("Lists all registered triggers").
            WithAnswerer(t.listStandardTriggers).
            Build()).
        WithCommand(actions.NewCommand().
            WithMatcher(matchNewEmojiTrigger).
            WithUsage("emoji trigger [anywhere] on <trigger string> with <reaction emojis>").
            WithDescription("Register an emoji trigger which will instruct me to emoji react with `reaction emojis` when someone says `trigger string`").
            WithAnswerer(t.registerEmojiTrigger).
            Build(),
        ).
        WithCommand(actions.NewCommand().
            WithMatcher(matchDeleteEmojiTrigger).
            WithUsage("forget emoji trigger on <trigger string>").
            WithDescription("Delete an emoji trigger on `trigger string`").
            WithAnswerer(t.deleteEmojiTrigger).
            Build(),
        ).
        WithCommand(actions.NewCommand().
            WithMatcher(func(m *slackscot.IncomingMessage) bool {
                return strings.HasPrefix(m.NormalizedText, "list emoji triggers")
            }).
            WithUsage("list emoji triggers").
            WithDescription("Lists all registered emoji triggers").
            WithAnswerer(t.listEmojiTriggers).
            Build(),
        ).
        Build()

    return t.Plugin
}

// matchNewTrigger returns true if the message matches the trigger registration regex
func matchNewTrigger(m *slackscot.IncomingMessage, triggerTypeID rune) bool {
    return triggerTypes[triggerTypeID].RegisterRegex.MatchString(m.NormalizedText)
}

// matchNewStandardTrigger returns true if the message matches the standard trigger registration regex
func matchNewStandardTrigger(m *slackscot.IncomingMessage) bool {
    return matchNewTrigger(m, standardTriggerTypeID)
}

// matchNewEmojiTrigger returns true if the message matches the emoji trigger registration regex
func matchNewEmojiTrigger(m *slackscot.IncomingMessage) bool {
    return matchNewTrigger(m, emojiTriggerTypeID)
}

// matchDeleteTrigger returns true if the message matches the delete trigger regex
func matchDeleteTrigger(m *slackscot.IncomingMessage, triggerTypeID rune) bool {
    return triggerTypes[triggerTypeID].DeleteRegex.MatchString(m.NormalizedText)
}

// matchDeleteStandardTrigger returns true if the message matches the delete standard trigger regex
func matchDeleteStandardTrigger(m *slackscot.IncomingMessage) bool {
    return matchDeleteTrigger(m, standardTriggerTypeID)
}

// matchDeleteEmojiTrigger returns true if the message matches the delete emoji trigger regex
func matchDeleteEmojiTrigger(m *slackscot.IncomingMessage) bool {
    return matchDeleteTrigger(m, emojiTriggerTypeID)
}

// matchTriggers returns true if the message matches one of the registered triggers
func (t *Triggerer) matchTriggers(m *slackscot.IncomingMessage) bool {
    triggersByType, err := t.getTriggersByType(m.Channel)
    if err != nil {
        t.Logger.Printf("Error loading triggers: %v", err)
        return false
    }

    for typeID, triggers := range triggersByType {
        for trigger := range triggers {
            exp, err := t.getTriggerRegexp(typeID, trigger)
            if err != nil {
                t.Logger.Printf("Error getting regexp for trigger [%s]: %v", trigger, err)
            }

            if exp.MatchString(m.NormalizedText) {
                return true
            }
        }
    }

    return false
}

// getTriggerRegexp generates a regexp for a given trigger. The resulting regexp is lazily cached so
// the regexes don't have to be recompiled every time
func (t *Triggerer) getTriggerRegexp(triggerTypeID rune, trigger string) (exp *regexp.Regexp, err error) {
    encTrigger := encodeTriggerWithTypeID(trigger, triggerTypeID)
    if exp, ok := t.triggerRegexes[encTrigger]; ok {
        return exp, nil
    }

    t.triggerRegexes[encTrigger], err = regexp.Compile(fmt.Sprintf("(?i)\\b%s\\b", regexp.QuoteMeta(trigger)))
    if err != nil {
        return nil, err
    }

    return t.triggerRegexes[encTrigger], nil
}

// reactOnTrigger reacts on emoji and standard triggers. For standard triggers, only the first match applies. For emoji triggers,
// all matching triggers apply. Note that both emoji triggers and a standard trigger can apply to the same message
func (t *Triggerer) reactOnTriggers(m *slackscot.IncomingMessage) *slackscot.Answer {
    triggersByType, err := t.getTriggersByType(m.Channel)
    if err != nil {
        t.Logger.Printf("Error loading triggers: %v", err)
        return nil
    }

    t.reactOnEmojiTriggers(m, triggersByType[emojiTriggerTypeID])
    return t.reactOnStandardTriggers(m, triggersByType[standardTriggerTypeID])
}

// reactOnStandardTriggers returns a reaction string if it finds a trigger match. Note that only at most one standard trigger can match
func (t *Triggerer) reactOnStandardTriggers(m *slackscot.IncomingMessage, standardTriggers map[string]string) *slackscot.Answer {
    for trigger, reaction := range standardTriggers {
        exp, err := t.getTriggerRegexp(standardTriggerTypeID, trigger)
        if err != nil {
            t.Logger.Printf("Error getting regexp for trigger [%s]: %v", trigger, err)
        }

        if exp.MatchString(m.NormalizedText) {
            return &slackscot.Answer{Text: reaction}
        }
    }

    return nil
}

// reactOnEmojiTriggers adds emoji reactions matching emoji triggers, as appropriate
func (t *Triggerer) reactOnEmojiTriggers(m *slackscot.IncomingMessage, emojiTriggers map[string]string) {
    for trigger, reaction := range emojiTriggers {
        exp, err := t.getTriggerRegexp(emojiTriggerTypeID, trigger)
        if err != nil {
            t.Logger.Printf("Error getting regexp for trigger [%s]: %v", trigger, err)
        }

        if exp.MatchString(m.NormalizedText) {
            for _, emoji := range parseEmojiList(reaction) {
                t.EmojiReactor.AddReaction(emoji, slack.NewRefToMessage(m.Channel, m.Timestamp))
            }
        }
    }
}

// registerTrigger adds or updates a trigger
func (t *Triggerer) registerTrigger(m *slackscot.IncomingMessage, triggerTypeID rune) *slackscot.Answer {
    triggerType := triggerTypes[triggerTypeID]
    silo, trigger, rawReaction := parseRegisterCommand(m, triggerType.RegisterRegex)
    encodedTrigger := encodeTriggerWithTypeID(trigger, triggerTypeID)
    encodedReaction, err := triggerType.ReactionEncoder(rawReaction)
    if err != nil {
        return &slackscot.Answer{Text: fmt.Sprintf("Invalid reaction for %s trigger: %s", triggerType.Name, err.Error()), Options: []slackscot.AnswerOption{slackscot.AnswerInThreadWithoutBroadcast()}}
    }

    renderedReaction := triggerType.ReactionRenderer(encodedReaction)
    answerMsg := fmt.Sprintf("Registered new %s trigger [`%s` => %s]", triggerType.Name, trigger, renderedReaction)

    encodedExistingReaction, err := t.triggerStorer.GetSiloString(silo, encodedTrigger)
    if encodedExistingReaction != "" {
        existingReactionRender := triggerType.ReactionRenderer(encodedExistingReaction)
        answerMsg = fmt.Sprintf("Replaced %s trigger reaction for [`%s`] with [%s] (was [%s] previously)", triggerType.Name, trigger, renderedReaction, existingReactionRender)
    }

    // Store new/updated trigger
    err = t.triggerStorer.PutSiloString(silo, encodedTrigger, encodedReaction)
    if err != nil {
        answerMsg = fmt.Sprintf("Error persisting %s trigger [`%s` => %s]: `%s`", triggerType.Name, trigger, renderedReaction, err.Error())
        t.Logger.Printf("[%s] %s", TriggererPluginName, answerMsg)

        return &slackscot.Answer{Text: answerMsg, Options: []slackscot.AnswerOption{slackscot.AnswerInThreadWithoutBroadcast()}}
    }

    t.Logger.Debugf("[%s] %s", TriggererPluginName, answerMsg)

    return &slackscot.Answer{Text: answerMsg, Options: []slackscot.AnswerOption{slackscot.AnswerInThreadWithoutBroadcast()}}
}

// registerStandardTrigger adds or updates a standard trigger
func (t *Triggerer) registerStandardTrigger(m *slackscot.IncomingMessage) *slackscot.Answer {
    return t.registerTrigger(m, standardTriggerTypeID)
}

// registerEmojiTrigger adds or updates an emoji trigger
func (t *Triggerer) registerEmojiTrigger(m *slackscot.IncomingMessage) *slackscot.Answer {
    return t.registerTrigger(m, emojiTriggerTypeID)
}

// encodeTriggerWithType encodes a trigger with its type
func encodeTriggerWithTypeID(trigger string, triggerTypeID rune) string {
    var b strings.Builder

    b.WriteRune(triggerTypeID)
    b.WriteString(trigger)

    return b.String()
}

// parseRegisterCommand parses the global mode, trigger and reaction from a command string
func parseRegisterCommand(m *slackscot.IncomingMessage, registerRegex *regexp.Regexp) (silo string, trigger string, rawReaction string) {
    matches := registerRegex.FindAllStringSubmatch(m.NormalizedText, -1)[0]

    where := strings.Trim(matches[1], " ")
    trigger = strings.Trim(matches[2], " ")
    reaction := strings.Trim(matches[3], " ")

    silo = m.Channel
    // if the optional "anywhere" was included in the instruction, set the silo
    // to the global one
    if strings.HasPrefix(where, "anywhere") {
        silo = globalSiloName
    }

    return silo, trigger, reaction
}

// renderSlackEmojis renders an array of emojis to a slack string using the :emoji: format
func renderSlackEmojis(emojis []string) (slackRenderedEmojis string) {
    renderedEmojis := make([]string, 0)

    for _, emojiName := range emojis {
        renderedEmojis = append(renderedEmojis, renderSlackEmoji(emojiName))
    }

    return strings.Join(renderedEmojis, ", ")
}

// renderSlackEmoji renders an emoji name (i.e. cat) into a slack emoji string (i.e. :cat:)
func renderSlackEmoji(emojiName string) (slackEmoji string) {
    var b strings.Builder

    b.WriteRune(':')
    b.WriteString(emojiName)
    b.WriteRune(':')

    return b.String()
}

// parseEmojiList returns an array of emojis for a comma-delimited list of emoji names (i.e. cat,dog,wave)
func parseEmojiList(emojiList string) (emojis []string) {
    return strings.Split(emojiList, emojiDelimiter)
}

// encodeEmojis returns a string representation of an array of emojis for storage (i.e. []string{"cat", "dog", "wave"} results in "cat,dog,wave")
func encodeEmojis(emojis []string) (encodedEmojiList string) {
    return strings.Join(emojis, emojiDelimiter)
}

// parseAllEmojis parses a reaction string and finds all emojis
func parseAllEmojis(reaction string) (emojis []string) {
    matches := emojiRegex.FindAllStringSubmatch(reaction, -1)
    emojis = make([]string, 0)

    for _, emojiMatch := range matches {
        emoji := emojiMatch[1]
        emojis = append(emojis, emoji)
    }

    return emojis
}

// deleteStandardTrigger deletes a trigger
func (t *Triggerer) deleteStandardTrigger(m *slackscot.IncomingMessage) *slackscot.Answer {
    return t.deleteTrigger(m, standardTriggerTypeID)
}

// deleteEmojiTrigger deletes an emoji trigger
func (t *Triggerer) deleteEmojiTrigger(m *slackscot.IncomingMessage) *slackscot.Answer {
    return t.deleteTrigger(m, emojiTriggerTypeID)
}

func (t *Triggerer) deleteTrigger(m *slackscot.IncomingMessage, triggerTypeID rune) *slackscot.Answer {
    triggerType := triggerTypes[triggerTypeID]
    matches := triggerType.DeleteRegex.FindAllStringSubmatch(m.NormalizedText, -1)[0]
    trigger := strings.Trim(matches[1], " ")

    a := t.deleteChannelTrigger(m.Channel, trigger, triggerType)
    if a == nil {
        // If there isn't a channel trigger, we assume the intent was to delete a global one so we try that
        a = t.deleteChannelTrigger(globalSiloName, trigger, triggerType)
    }

    if a != nil {
        return a
    }

    answerMsg := fmt.Sprintf("No %s trigger found on `%s`", triggerType.Name, trigger)
    return &slackscot.Answer{Text: answerMsg, Options: []slackscot.AnswerOption{slackscot.AnswerInThreadWithoutBroadcast()}}
}

// deleteChannelTrigger deletes a trigger for the given channel (which is the silo the trigger is stored in).
// This is meant to allow deleting a trigger for a specific channel but also a global one using the globalSiloName
func (t *Triggerer) deleteChannelTrigger(channel string, trigger string, ttype triggerType) *slackscot.Answer {
    encodedTrigger := encodeTriggerWithTypeID(trigger, ttype.ID)
    existingEncodedReaction, err := t.triggerStorer.GetSiloString(channel, encodedTrigger)
    if existingEncodedReaction != "" {
        existingReactionRender := ttype.ReactionRenderer(existingEncodedReaction)

        // Delete trigger
        err = t.triggerStorer.DeleteSiloString(channel, encodedTrigger)
        if err != nil {
            answerMsg := fmt.Sprintf("Error removing %s trigger [`%s` => %s]: `%s`", ttype.Name, trigger, existingReactionRender, err.Error())
            t.Logger.Printf("[%s] %s", TriggererPluginName, answerMsg)

            return &slackscot.Answer{Text: answerMsg, Options: []slackscot.AnswerOption{slackscot.AnswerInThreadWithoutBroadcast()}}
        }

        answerMsg := fmt.Sprintf("Deleted %s trigger [`%s` => %s]", ttype.Name, trigger, existingReactionRender)
        t.Logger.Debugf("[%s] %s", TriggererPluginName, answerMsg)

        return &slackscot.Answer{Text: answerMsg, Options: []slackscot.AnswerOption{slackscot.AnswerInThreadWithoutBroadcast()}}
    }

    return nil
}

// listStandardTriggers returns a message with the full list of registered triggers
func (t *Triggerer) listStandardTriggers(m *slackscot.IncomingMessage) *slackscot.Answer {
    return t.listTriggers(m.Channel, "Here are the current triggers: \n", standardTriggerTypeID)
}

// listEmojiTriggers returns a message with the full list of registered triggers
func (t *Triggerer) listEmojiTriggers(m *slackscot.IncomingMessage) *slackscot.Answer {
    return t.listTriggers(m.Channel, "Here are the current emoji triggers: \n", emojiTriggerTypeID)
}

// listTriggers renders a list of triggers in a table contained in a code block
func (t *Triggerer) listTriggers(channelID string, header string, triggerTypeID rune) *slackscot.Answer {
    triggerType := triggerTypes[triggerTypeID]

    triggersByType, err := t.getTriggersByType(channelID)
    if err != nil {
        t.Logger.Printf("Error loading triggers: %v", err)
        return &slackscot.Answer{Text: fmt.Sprintf("Error loading triggers:\n```%s```", err.Error()), Options: []slackscot.AnswerOption{slackscot.AnswerInThreadWithoutBroadcast()}}
    }

    triggers := triggersByType[triggerTypeID]

    var buffer bytes.Buffer

    buffer.WriteString(header)
    buffer.WriteString(formatTriggers(triggers, triggerType.SlackRender))
    return &slackscot.Answer{Text: buffer.String(), Options: []slackscot.AnswerOption{slackscot.AnswerInThreadWithoutBroadcast()}}
}

// getTriggers returns all triggers by trigger type for a given channel ID. All trigger types are processed
// and callers can safely assume that an entry exists in the returned map for all types even
// if no triggers exists for it (this would be an empty map of triggers => reaction for that type)
func (t *Triggerer) getTriggersByType(channelID string) (byType map[rune]map[string]string, err error) {
    // Start adding global triggers
    triggers, err := t.triggerStorer.ScanSilo(globalSiloName)
    if err != nil {
        return nil, err
    }

    chTriggers, err := t.triggerStorer.ScanSilo(channelID)
    if err != nil {
        return nil, err
    }

    // Add channel-specific triggers, overriding any duplicates so that the channel version
    // wins
    for k, v := range chTriggers {
        triggers[k] = v
    }

    byType = make(map[rune]map[string]string)
    // Initialize maps for all trigger types
    for triggerTypeID := range triggerTypes {
        byType[triggerTypeID] = make(map[string]string)
    }

    for rawTrigger, reaction := range triggers {
        if len(rawTrigger) > 0 {
            triggerAsRunes := []rune(rawTrigger)
            triggerType := triggerAsRunes[0]
            triggerValue := triggerAsRunes[1:]
            byType[triggerType][string(triggerValue)] = reaction
        }
    }

    return byType, nil
}

// formatTriggers formats the list of triggers
func formatTriggers(triggers map[string]string, render elementRenderer) string {
    keys := make([]string, 0)
    for k := range triggers {
        keys = append(keys, k)
    }
    sort.Strings(keys)

    var b bytes.Buffer
    w := new(tabwriter.Writer)
    bufw := bufio.NewWriter(&b)
    w.Init(bufw, 5, 0, 1, ' ', 0)
    for _, trigger := range keys {
        reaction := triggers[trigger]
        fmt.Fprintf(w, "\t• %s\n", render(trigger, reaction))
    }
    fmt.Fprintf(w, "\n")

    bufw.Flush()
    w.Flush()
    return b.String()
}