diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/gopherbot.iml b/.idea/gopherbot.iml new file mode 100644 index 0000000..5e764c4 --- /dev/null +++ b/.idea/gopherbot.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..16e8843 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/README.md b/README.md index 02ac7af..d699b08 100644 --- a/README.md +++ b/README.md @@ -1,50 +1,51 @@ # gopherbot -This is a rewrite of the [original](https://github.com/gobridge/gopher) Go Slack -Workspace chat bot. This big difference between that version and this, is that -`gopherbot` uses the Slack Events API instead of the antiquated RTM (WebSocket) +This is the Go Slack Workspace bot. It uses the Slack Events API instead of the antiquated RTM (WebSocket) API. -This unfortunately results in a more complicated queue-based architecture, that -results in a more resilient chat bot. As a result of it now being a queue-based -system, the bot will not respond to any messages more than 30 seconds ago. So if -Slack experiences an issue preventing us from replying, the message will be -dropped. +The bot will not respond to any messages more than 30 seconds ago. +If Slack experiences an issue preventing it from replying, the message will be dropped. ## Contributing + ### Adding Responses / Reactions -Pretty much all responses and reactions should be configured in the -[cmd/consumer/](https://github.com/gobridge/gopherbot/tree/master/cmd/consumer) -directory, with each thing being split out by file. How to configure each should -be fairly straightforward based on existing examples, and the usage of the -`handler` package is documented via GoDoc if you have any questions. + +All responses and reactions should be configured in the +[cmd/consumer/](https://github.com/gobridge/gopherbot/tree/main/cmd/consumer) +directory. + +Each action should be split out by file. + +For examples on how to configure and the use the various parts of the bot, +check out the [handler](handler) [package documentation](https://pkg.go.dev/github.com/gobridge/gopherbot/handler). ### Adding Definitions to Glossary + There is also the `define` command that is powered by the `glossary` package. If -you'd like to add definitions to the glossary, you can [do it -here](https://github.com/gobridge/gopherbot/blob/master/glossary/terms.go#L5) +you'd like to add definitions to the glossary, you can [do it here](https://github.com/gobridge/gopherbot/blob/main/glossary/terms.go#L5) and raise a PR against this repo. The glossary is meant to contain common words and terms relevant to the Go community. It's not Urban Dictionary. ## Architecture + ### Slack API -As mentioned above, the old version used the RTM API for interacting with Slack. -This is no longer the recommended API to use for building integrations, with -them now suggesting [The Slack Events API](https://api.slack.com/events-api). + +As mentioned above, the bot uses [The Slack Events API](https://api.slack.com/events-api). It's a HTTP+JSON based subscription model, with strict requirements on message acknowledgment times on delivery. Based on that, the best way to accept events is to write them to a queue to be processed by workers later so that some slow -task doesn't violate the contract or introduce the risk of lossy message -processing. +task doesn't break the contract or introduce the risk of lossy message processing. The Events API offers signing of requests, so that you can be confident the request originated from Slack. ### Components + #### Gateway + The job for the gateway is to cryptographically validate the incoming event from Slack, confirm that it contains the metadata we expect, and then forward the message on to the work queue. @@ -60,6 +61,7 @@ There is effectively one queue for event type: The gateway is stateless and can be scaled horizontally. #### Consumer + The consumer registers a handler for each of the queues, and those handlers process each message internally. They themselves may have sub-handlers that get executed, like reacting to messages with emoji versus responding to them. @@ -70,8 +72,9 @@ update to the workspace join message this is the component that handles those. The consumer is stateless and can be scaled horizontally. #### BGTasks -The `bgtasks` component is meant to be a place where regular background jobs are -ran, such as filling data caches, polling for Gerrit (Go CL) merges, or GoTime + +The [bgtasks](cmd/bgtasks) component is meant to be a place where regular background +jobs run, such as filling data caches, polling for Gerrit (Go CL) merges, or GoTime shows starting. This currently has a channel cache poller, so that consumer handlers can look up @@ -112,15 +115,18 @@ environment with these environment variables: | `HEROKU_SLUG_COMMIT` | The commit of the code running. This is used in logging, and should be set. | ## Deployment -The bot is currently running under the GoBridge Heroku organization, and merges -to master are automatically deployed to the staging version (`@glenda`**. If a -merge to master seems to have deployed okay automatically, you need to go into -the Heroku UI and and promote each running app to production. - -**Please Note:** Because Bill had requested our repo be a monorepo, our Heroku -deployment configuration is an operational landmine. When clicking the "Promote -to Production" button, you need to deselect the unrelated apps so that you don't -accidentally promote the wrong build to production. For example, if you're -promoting the gateway component you need to make sure not to promote it to the -bgtasks or consumer apps. This will break the bot, and require some manual -action to fix the production deployment. + +The bot is currently running under the GoBridge Heroku organization. + +Merges to `main` are automatically deployed to the staging version `@glenda`**. +If a merge to `main` was deployed successfully, you need to go into +the Heroku UI and promote each running app to production. + +**Please Note:** When clicking the `Promote to Production` button, +you need to deselect the unrelated apps so that you don't accidentally +promote the wrong build to production. + +For example, if you're promoting the gateway component you need to make +sure not to promote it to the `bgtasks` or `consumer` apps. +This will break the bot, and require some manual action to fix the +production deployment. diff --git a/cmd/bgtasks/bgtasks.go b/cmd/bgtasks/bgtasks.go index d50f964..87e5df2 100644 --- a/cmd/bgtasks/bgtasks.go +++ b/cmd/bgtasks/bgtasks.go @@ -117,7 +117,6 @@ func newHTTPTransport() *http.Transport { DialContext: (&net.Dialer{ Timeout: 10 * time.Second, KeepAlive: 30 * time.Second, - DualStack: true, }).DialContext, MaxIdleConns: 100, IdleConnTimeout: 60 * time.Second, diff --git a/cmd/consumer/consumer.go b/cmd/consumer/consumer.go index 7bc147e..bb30775 100644 --- a/cmd/consumer/consumer.go +++ b/cmd/consumer/consumer.go @@ -13,7 +13,6 @@ import ( "github.com/go-redis/redis" "github.com/gobridge/gopherbot/cache" - "github.com/gobridge/gopherbot/cmd/consumer/playground" "github.com/gobridge/gopherbot/config" "github.com/gobridge/gopherbot/glossary" "github.com/gobridge/gopherbot/handler" @@ -23,16 +22,6 @@ import ( "github.com/slack-go/slack" ) -// playgroundChannelBlacklist sets a list of channels the playground uploader will -// not operate in -var playgroundChannelBlacklist = []string{ - "C4U9J9QBT", // #admin-help - "C029RQSEG", // #random - "G1L7RN06B", // admin private channel - "G207C8R1R", // gobridge ops chanel - "GB1KBRGKA", // modnar (private random channel) -} - func getSelf(c *slack.Client) (*slack.User, error) { // full lifetime of this function ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) @@ -69,7 +58,7 @@ func runServer(cfg config.C, logger zerolog.Logger) error { sc := slack.New(cfg.Slack.BotAccessToken, slack.OptionHTTPClient(newHTTPClient())) - // test credentails and get self reference + // test credentials and get self reference self, err := getSelf(sc) if err != nil { return err @@ -157,11 +146,6 @@ func runServer(cfg config.C, logger zerolog.Logger) error { // handle "define " prefixed command ma.HandlePrefix(glossary.Prefix, "find a definition in the glossary of Go-related terms", gloss.DefineHandler) - // set up the Go Playground uploader - lp := logger.With().Str("context", "playground") - pg := playground.New(newHTTPClient(), lp.Logger(), playgroundChannelBlacklist) - ma.HandleDynamic(pg.MessageMatchFn, pg.Handler) - injectTeamJoinHandlers(tja) injectChannelJoinHandlers(cja) @@ -201,7 +185,6 @@ func newHTTPTransport() *http.Transport { DialContext: (&net.Dialer{ Timeout: 10 * time.Second, KeepAlive: 30 * time.Second, - DualStack: true, }).DialContext, MaxIdleConns: 100, IdleConnTimeout: 60 * time.Second, diff --git a/cmd/consumer/playground/playground.go b/cmd/consumer/playground/playground.go deleted file mode 100644 index eb3527b..0000000 --- a/cmd/consumer/playground/playground.go +++ /dev/null @@ -1,235 +0,0 @@ -// Package playground provides a handler.MatchFn and a Client struct with a -// Handler method that can be used as handler.ActionFn. -package playground - -import ( - "bytes" - "context" - "fmt" - "html" - "io" - "io/ioutil" - "net/http" - "strings" - "time" - - "github.com/gobridge/gopherbot/handler" - "github.com/gobridge/gopherbot/mparser" - "github.com/gobridge/gopherbot/workqueue" - "github.com/rs/zerolog" -) - -// Client is the Go Playground client. -type Client struct { - httpc *http.Client - logger zerolog.Logger - blacklist map[string]struct{} -} - -// New takes an HTTP client and returns a Playground Client. If httpc is nil -// this program will probably panic at some point. -func New(httpc *http.Client, logger zerolog.Logger, channelBlacklist []string) *Client { - m := make(map[string]struct{}, len(channelBlacklist)) - - for _, cid := range channelBlacklist { - m[cid] = struct{}{} - } - - return &Client{ - httpc: httpc, - logger: logger, - blacklist: m, - } -} - -// Handler is a handler.ActionFn. -func (c *Client) Handler(ctx workqueue.Context, m handler.Messenger, r handler.Responder) error { - for _, file := range m.Files() { - if file.Filetype == "go" || file.Filetype == "text" { - return c.pgForFiles(ctx, m, r) - } - } - - return c.pgForMessage(ctx, m, r) -} - -func (c *Client) pgForMessage(ctx workqueue.Context, m handler.Messenger, r handler.Responder) error { - - link, err := c.upload(ctx, messageToPlayground(m.Text())) - if err != nil { - return fmt.Errorf("failed to upload to playground: %w", err) - } - - mention := mparser.Mention{ - Type: mparser.TypeUser, - ID: m.UserID(), - } - - msg := fmt.Sprintf("The above code from %s in the playground: <%s>", mention.String(), link) - - err = r.Respond(ctx, msg) - if err != nil { - return fmt.Errorf("failed to send message with Playground link: %w", err) - } - - err = r.RespondEphemeral(ctx, `I've noticed you've written a large block of text (more than 9 lines). `+ - `To faciliate collaboration and make the conversation easier to follow, `+ - `please consider using to share code. If you wish to not `+ - `link against the playground, please start the message with "nolink". Thank you!`, - ) - if err != nil { - ctx.Logger().Error(). - Err(err). - Msg("failed to respond with ephemeral Go Playground etiquette") - } - - return nil -} - -func (c *Client) pgForFiles(ctx workqueue.Context, m handler.Messenger, r handler.Responder) error { - sc := ctx.Slack() - files := m.Files() - - // XXX(theckman): following comment and code has been copied verbatim from gopherv1 - // - // Empirically, attempting to call GetFileInfoContext too quickly after a - // file is uploaded can cause a "file_not_found" error. - time.Sleep(1 * time.Second) - - for _, f := range files { - i, _, _, err := sc.GetFileInfoContext(ctx, f.ID, 0, 0) - if err != nil { - return fmt.Errorf("failed to get file info for %s: %w", f.ID, err) - } - - if i.Lines < 6 || i.PrettyType == "Plain Text" { - return nil - } - - buf := &bytes.Buffer{} - err = sc.GetFile(i.URLPrivateDownload, buf) - if err != nil { - return fmt.Errorf("failed to get file %s: %w", f.ID, err) - } - - link, err := c.upload(ctx, buf) - if err != nil { - return fmt.Errorf("failed to upload to playground: %w", err) - } - - mention := mparser.Mention{ - Type: mparser.TypeUser, - ID: m.UserID(), - } - - msg := fmt.Sprintf("The above code from %s in the playground: <%s>", mention.String(), link) - err = r.Respond(ctx, msg) - if err != nil { - return fmt.Errorf("failed to send message with Playground link: %w", err) - } - } - - err := r.RespondEphemeral(ctx, `I've noticed you uploaded a Go file. To facilitate collaboration and make `+ - `it easier for others to share back the snippet, please consider using: `+ - `. If you wish to not link against the playground, please use `+ - `"nolink" in the message. Thank you!`, - ) - if err != nil { - ctx.Logger().Error(). - Err(err). - Msg("failed to respond with ephemeral Go Playground etiquette") - } - - return nil -} - -func (c *Client) upload(ctx context.Context, body io.Reader) (link string, err error) { - req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://play.golang.org/share", body) - if err != nil { - return "", err - } - - req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8") - req.Header.Add("User-Agent", "Gophers Slack Bot V2") - - resp, err := c.httpc.Do(req) - if err != nil { - return "", err - } - - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != 200 { - return "", fmt.Errorf("unexpected HTTP response status: %s", resp.Status) - } - - id, err := ioutil.ReadAll(resp.Body) - if err != nil { - return "", fmt.Errorf("failed to read response body: %w", err) - } - - return "https://play.golang.org/p/" + string(id), nil -} - -// MessageMatchFn satisfies handler.MessageMatchFn -func (c *Client) MessageMatchFn(shadowMode bool, m handler.Messenger) bool { - // channel is blacklisted - if _, ok := c.blacklist[m.ChannelID()]; ok { - c.logger.Debug(). - Str("reason", "channel not permitted"). - Msg("playground match skipped") - return false - } - - rt := m.RawText() - - if strings.Contains(rt, "nolink") || (len(m.Files()) == 0 && strings.Count(rt, "\n") < 10) { - return false - } - - if shadowMode { - c.logger.Debug(). - Str("reason", "shadow mode"). - Msg("playground match skipped") - - return false - } - - return true -} - -// messageToPlayground converts the text of a post into code for the playground. It is not perfect but works most of the time. -// Text outside of ``` quotes is converted into a comment and included in the code, everything inside of those quotes is -// considered code and pasted as-is. -func messageToPlayground(text string) *bytes.Buffer { - var buf bytes.Buffer - - // unescape the post to prevent the insertion of HTML escapes into the playground - text = html.UnescapeString(text) - parts := strings.Split(text, "```") - - for i, part := range parts { - part = strings.Trim(part, "\n") - - if i&1 == 0 { - // it's a comment - if strings.TrimSpace(part) == "" { - continue - } - - buf.WriteString("\n// ") - buf.WriteString(strings.Replace(part, "\n", "\n// ", -1)) - buf.WriteString("\n\n") - } else { - // it's code - if part == "" { - continue - } - - buf.WriteString(part) - buf.WriteByte('\n') - } - } - - return &buf -} diff --git a/cmd/consumer/reactions.go b/cmd/consumer/reactions.go index 8dc46e3..b307d2c 100644 --- a/cmd/consumer/reactions.go +++ b/cmd/consumer/reactions.go @@ -6,8 +6,6 @@ func injectMessageReactions(r *handler.MessageActions) { r.HandleReaction("bbq", "bbqgopher") r.HandleReaction("ghost", "ghost") r.HandleReaction("spacex", "rocket") - r.HandleReaction("buffalo", "gobuffalo") - r.HandleReaction("gobuffalo", "gobuffalo") r.HandleReaction("spacemacs", "spacemacs") r.HandleReaction("my adorable little gophers", "gopher") diff --git a/cmd/consumer/responses.go b/cmd/consumer/responses.go index d8f655e..ab3e8a2 100644 --- a/cmd/consumer/responses.go +++ b/cmd/consumer/responses.go @@ -97,7 +97,7 @@ func injectMessageResponseFuncs(ma *handler.MessageActions) { continue // weird... } - fmt.Fprintf(builder, "- <#%s> -> %s\n", c.ID, channel.desc) + _, _ = fmt.Fprintf(builder, "- <#%s> -> %s\n", c.ID, channel.desc) } @@ -144,26 +144,26 @@ When replying in the channel, you can at-mention the person you're directing the } // print each command, with aliases on their own line - fmt.Fprintf(b, "- `%s`: %s\n", h.Trigger, h.Description) + _, _ = fmt.Fprintf(b, "- `%s`: %s\n", h.Trigger, h.Description) if len(h.Aliases) > 0 { a := strings.Join(fmtAliases(h.Aliases), ",") - fmt.Fprintf(b, "\t- aliases: %s\n", a) + _, _ = fmt.Fprintf(b, "\t- aliases: %s\n", a) } - fmt.Fprintln(b) + _, _ = fmt.Fprintln(b) } // if we have some prefixed commands, do it again if hasPrefix { - fmt.Fprint(b, "\n\nThere are also these special message prefixes:\n\n") + _, _ = fmt.Fprint(b, "\n\nThere are also these special message prefixes:\n\n") for _, h := range hs { if !h.Prefix { continue } - fmt.Fprintf(b, "- `%s`: %s\n\n", h.Trigger, h.Description) + _, _ = fmt.Fprintf(b, "- `%s`: %s\n\n", h.Trigger, h.Description) } } @@ -175,10 +175,9 @@ When replying in the channel, you can at-mention the person you're directing the func injectMessageResponses(ma *handler.MessageActions) { ma.HandleStatic("recommended", "returns a list of recommended blogs or twitter feeds", []string{"recommended blogs"}, `Here are some popular blog posts and Twitter accounts you should follow:`, - `- Peter Bourgon - `, `- Carlisia Campos `, - `- Dave Cheney - `, - `- Jaana Burcu Dogan - `, + `- Dave Cheney - `, + `- Jaana Burcu Dogan - `, `- Jessie Frazelle - `, `- William "Bill" Kennedy - `, `- Brian Ketelsen - `, @@ -196,7 +195,7 @@ func injectMessageResponses(ma *handler.MessageActions) { ) ma.HandleStatic("work with forks", "info on how to work with forks in Go", []string{"working with forks"}, - `Here's how to work with package forks in Go: `, + `Here's how to work with package forks in Go: `, ) ma.HandleStatic("block forever", "how to block forever", []string{"how to block forever"}, @@ -227,7 +226,6 @@ func injectMessageResponses(ma *handler.MessageActions) { `These articles will explain how to organize your Go packages:`, `- `, `- `, - `- `, `- `, ``, `This article will help you understand the design philosophy for packages: `, @@ -280,7 +278,7 @@ func injectMessageResponses(ma *handler.MessageActions) { ma.HandleStatic("code of conduct", "info about the code of conduct", []string{"coc"}, `We're all expected to follow the GoBridge Code of Conduct, which is itself a superset of the Go Community Code of Conduct. You can find both here:`, - `- `, + `- `, `- `, `If you have any questions or concerns please reach out in <#C4U9J9QBT> or email support@gobridge.org.`, ) @@ -364,7 +362,7 @@ Then, you should visit: There are some awesome websites as well: - great resources for Gophers in general - - awesome weekly podcast of Go awesomeness + - awesome weekly podcast of Go awesomeness - examples of how to do things in Go - how to use SQL databases in Go - tips on how to write more idiomatic Go code @@ -372,10 +370,8 @@ There are some awesome websites as well: - tutorials to help you get started in Go - a collection of articles around various aspects of Go -There's also an exhaustive list of videos related to Go from various authors. - If you prefer books, you can try these: - - + - - - (if you e-mail @wkennedy at bill@ardanlabs.com you can get a free copy for being part of this Slack) diff --git a/go.mod b/go.mod index 9a66ff2..d7c24ee 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,8 @@ -// +heroku goVersion go1.14 +// +heroku goVersion go1.16 module github.com/gobridge/gopherbot -go 1.14 +go 1.16 require ( github.com/go-redis/redis v6.15.7+incompatible diff --git a/handler/message_actions.go b/handler/message_actions.go index 3580e9b..5a947f4 100644 --- a/handler/message_actions.go +++ b/handler/message_actions.go @@ -436,13 +436,13 @@ func (m *MessageActions) HandleReactionRand(trigger string, reactions ...string) } m.reactions[trigger] = reactiveAction{ - fn: reactionFactory(true, 0x2A, reactions...), + fn: reactionFactory(true, 42, reactions...), } } func reactionFactory(random bool, randFactor int, reactions ...string) func(ctx workqueue.Context, m Messenger, r Responder) error { return func(ctx workqueue.Context, m Messenger, r Responder) error { - if random && rand.Intn(150) != 0x2A { // not this time, maybe next time! + if random && rand.Intn(150) != randFactor { // not this time, maybe next time! return nil } diff --git a/internal/poller/gotime/gotime.go b/internal/poller/gotime/gotime.go index feeef1d..fb9cf8d 100644 --- a/internal/poller/gotime/gotime.go +++ b/internal/poller/gotime/gotime.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "io" "io/ioutil" "net/http" "time" @@ -38,9 +39,9 @@ type GoTime struct { // startTimeVariance sets the window around the stream's start time when // a live steam will be considered a GoTime live stream. This is necessary // because the current changelog APIs return whether any show is streaming -// rather thahn GoTime specifically. +// rather than GoTime specifically. // -// notify is called when streaming starts. notify should return true when a successful. +// notify is called when streaming starts. notify should return true when successful. func New(s Store, c *http.Client, logger zerolog.Logger, startTimeVariance time.Duration, notify NotifyFunc) (*GoTime, error) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() @@ -117,7 +118,7 @@ func (gt *GoTime) Poll(ctx context.Context) error { Msg("sending notification that it's Go Time") if err := gt.notify(ctx); err != nil { - return fmt.Errorf("Go Time notification failed: %w", err) + return fmt.Errorf("failed notification for Go Time: %w", err) } gt.lastNotified = now @@ -141,7 +142,9 @@ func (gt *GoTime) get(ctx context.Context, url string, i interface{}) error { if err != nil { return fmt.Errorf("making http request: %v", err) } - defer resp.Body.Close() + defer func(Body io.ReadCloser) { + _ = Body.Close() + }(resp.Body) if resp.StatusCode != http.StatusOK { return fmt.Errorf("non-200 status code: %d - %s", resp.StatusCode, resp.Status)