Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Socket Mode support #885

Merged
merged 57 commits into from
Jan 19, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
2873e9b
WIP: Socket Mode support
mumoshu Jan 14, 2021
14923f2
Use app token for opening socket mode connection
mumoshu Jan 15, 2021
ff6e556
Move Socket Mode support to the slacksocketmode pkg to avoid import c…
mumoshu Jan 15, 2021
88c913f
Move `backoff` to the internal/backoff pkg for sharing between slack …
mumoshu Jan 15, 2021
493fd6f
Fix the comment on ack method
mumoshu Jan 15, 2021
d472ffd
Remove invalid comment left due to copy-paste on SocketModeClient.con…
mumoshu Jan 15, 2021
e97de46
Fix SocketModeClient receiver name
mumoshu Jan 15, 2021
f5c72e4
Various renames on Socket Mode related symbols for ease of use and co…
mumoshu Jan 15, 2021
08f68aa
Move statusCodeError into the internal pkg for use from slacksocketmode
mumoshu Jan 16, 2021
155b2bc
The initial version that manually verified to work
mumoshu Jan 16, 2021
cf7f3ca
Fix log message
mumoshu Jan 16, 2021
38b0347
Better debug logs (may be removed before merging)
mumoshu Jan 16, 2021
b53014b
Change socketmode example source file name for consistency
mumoshu Jan 16, 2021
4e25959
Call it a Socket Mode "Request" rather than "Message" or "Event" for …
mumoshu Jan 16, 2021
528677c
More restructuring for readability
mumoshu Jan 16, 2021
5e41380
Refactor socket mode deadman for clarity
mumoshu Jan 16, 2021
1a23793
More Event and Request distinction in code-base
mumoshu Jan 16, 2021
0d6994c
Fix socket mode debug log message
mumoshu Jan 16, 2021
1da2c76
Remove unnecessary call to SetCloseHandler
mumoshu Jan 16, 2021
5b8a218
Send back PONG to Slack via WebSocket in Socket Mode
mumoshu Jan 16, 2021
e5d2f1a
Delete unused handleAck function for slacksocketmode
mumoshu Jan 16, 2021
c269a17
Serialize writes to WebSocket for Socket Mode ACKs
mumoshu Jan 16, 2021
852061e
Rename slacksocketmode to socketmode
mumoshu Jan 16, 2021
11f20f3
Merge internalEvent and externalEvent
mumoshu Jan 16, 2021
3cd2344
Apply goimports
mumoshu Jan 16, 2021
7e8a5a7
Give socketmode Response a dedicated source file
mumoshu Jan 16, 2021
93c9b45
Fix an incorrect envvar name in socketmode example
mumoshu Jan 16, 2021
88722e9
Cover all the possible Socket Mode request types
mumoshu Jan 16, 2021
f0a6dde
This may make govet lint happy
mumoshu Jan 16, 2021
0be002a
Add support for receiving `interactive` and `slash_commands` requests…
mumoshu Jan 16, 2021
24cf166
Force the consumer to explicitly ack the socket mode request and veri…
mumoshu Jan 16, 2021
1265022
Various cleanups
mumoshu Jan 17, 2021
10850e1
Rename pingInterval to maxPingInterval for clarity
mumoshu Jan 17, 2021
ebbe82a
Add missing Debugf func
mumoshu Jan 17, 2021
860b3a2
Various fixes regarding build errors
mumoshu Jan 17, 2021
aa57150
Remove the private variant of Ack func and add comments
mumoshu Jan 17, 2021
679c9a6
Remove unneeded ticker in socket mode message handler
mumoshu Jan 17, 2021
40cedb3
socketmode: Make WebSocket message handling core a bit more side-effe…
mumoshu Jan 17, 2021
ff9e9fd
Distinguish among done vs disconnect vs reconnect
mumoshu Jan 17, 2021
91b8ac7
socket mode: Add support for disconnect and reconnect on request from…
mumoshu Jan 17, 2021
e5a4822
socket mode: Make Run() a bit more easy to read
mumoshu Jan 17, 2021
5a2d8db
socket mode: Make slash-command related event value consistent with r…
mumoshu Jan 17, 2021
18c4fd2
Fix examples/socketmode for invalid member-joined-channel event match…
mumoshu Jan 17, 2021
9335441
socketmode: Add support for Go 1.12
mumoshu Jan 17, 2021
b2bfdf1
More unit tests for socket mode request parsing and some tweaks
mumoshu Jan 18, 2021
eaaf0e0
socketmode: Fix logger for correct line numbers in messages
mumoshu Jan 18, 2021
a606305
socketmode: Remove unused idGen
mumoshu Jan 18, 2021
93df48b
socketmode: Fix comment on Client.Open()
mumoshu Jan 18, 2021
d7a26ac
socketmode: make receiver name "smc"
mumoshu Jan 18, 2021
2cf901c
socketmode: Remove RTM-ness from comments on log messages
mumoshu Jan 18, 2021
2cbdf3b
socketmode: Use EventType constants everywhere
mumoshu Jan 18, 2021
04ead06
socketmode: Update the example with connectino lifecycle events
mumoshu Jan 18, 2021
e9e7822
socketmode: prepare costly log data only on debug mode
mumoshu Jan 18, 2021
c0cd3da
Remove unnecessary commented-out line
mumoshu Jan 18, 2021
fcf2b1e
Fix comment on StartSocketModeContext
mumoshu Jan 18, 2021
0f4bd7d
socketmode: Brush up comment on socketmode.Client
mumoshu Jan 19, 2021
0fbd39a
socketmode: Make internal error private to the pkg as it should
mumoshu Jan 19, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
149 changes: 149 additions & 0 deletions examples/socketmode/socketmode.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
package main

import (
"fmt"
"log"
"os"
"strings"

"github.com/slack-go/slack/socketmode"

"github.com/slack-go/slack"
"github.com/slack-go/slack/slackevents"
)

func main() {
appToken := os.Getenv("SLACK_APP_TOKEN")
if appToken == "" {

}

if !strings.HasPrefix(appToken, "xapp-") {
fmt.Fprintf(os.Stderr, "SLACK_APP_TOKEN must have the prefix \"xapp-\".")
}

botToken := os.Getenv("SLACK_BOT_TOKEN")
if botToken == "" {
fmt.Fprintf(os.Stderr, "SLACK_BOT_TOKEN must be set.\n")
os.Exit(1)
}

if !strings.HasPrefix(botToken, "xoxb-") {
fmt.Fprintf(os.Stderr, "SLACK_BOT_TOKEN must have the prefix \"xoxb-\".")
}

api := slack.New(
botToken,
slack.OptionDebug(true),
slack.OptionLog(log.New(os.Stdout, "api: ", log.Lshortfile|log.LstdFlags)),
slack.OptionAppLevelToken(appToken),
)

client := socketmode.New(
api,
socketmode.OptionDebug(true),
socketmode.OptionLog(log.New(os.Stdout, "socketmode: ", log.Lshortfile|log.LstdFlags)),
)

go func() {
for evt := range client.Events {
switch evt.Type {
case socketmode.EventTypeConnecting:
fmt.Println("Connecting to Slack with Socket Mode...")
case socketmode.EventTypeConnectionError:
fmt.Println("Connection failed. Retrying later...")
case socketmode.EventTypeConnected:
fmt.Println("Connected to Slack with Socket Mode.")
case socketmode.EventTypeEventsAPI:
eventsAPIEvent, ok := evt.Data.(slackevents.EventsAPIEvent)
if !ok {
fmt.Printf("Ignored %+v\n", evt)

continue
}

fmt.Printf("Event received: %+v\n", eventsAPIEvent)

client.Ack(*evt.Request)

switch eventsAPIEvent.Type {
case slackevents.CallbackEvent:
innerEvent := eventsAPIEvent.InnerEvent
switch ev := innerEvent.Data.(type) {
case *slackevents.AppMentionEvent:
_, _, err := api.PostMessage(ev.Channel, slack.MsgOptionText("Yes, hello.", false))
if err != nil {
fmt.Printf("failed posting message: %v", err)
}
case *slackevents.MemberJoinedChannelEvent:
fmt.Printf("user %q joined to channel %q", ev.User, ev.Channel)
}
default:
client.Debugf("unsupported Events API event received")
}
case socketmode.EventTypeInteractive:
callback, ok := evt.Data.(slack.InteractionCallback)
if !ok {
fmt.Printf("Ignored %+v\n", evt)

continue
}

fmt.Printf("Interaction received: %+v\n", callback)

var payload interface{}

switch callback.Type {
case slack.InteractionTypeBlockActions:
// See https://api.slack.com/apis/connections/socket-implement#button

client.Debugf("button clicked!")
case slack.InteractionTypeShortcut:
case slack.InteractionTypeViewSubmission:
// See https://api.slack.com/apis/connections/socket-implement#modal
case slack.InteractionTypeDialogSubmission:
default:

}

client.Ack(*evt.Request, payload)
case socketmode.EventTypeSlashCommand:
cmd, ok := evt.Data.(slack.SlashCommand)
if !ok {
fmt.Printf("Ignored %+v\n", evt)

continue
}

client.Debugf("Slash command received: %+v", cmd)

payload := map[string]interface{}{
"blocks": []slack.Block{
slack.NewSectionBlock(
&slack.TextBlockObject{
Type: slack.MarkdownType,
Text: "foo",
},
nil,
slack.NewAccessory(
slack.NewButtonBlockElement(
"",
"somevalue",
&slack.TextBlockObject{
Type: slack.PlainTextType,
Text: "bar",
},
),
),
),
}}

client.Ack(*evt.Request, payload)
default:
fmt.Fprintf(os.Stderr, "Unexpected event type received: %s\n", evt.Type)
}
}
}()

client.Run()
}
13 changes: 9 additions & 4 deletions backoff.go → internal/backoff/backoff.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package slack
package backoff

import (
"math/rand"
Expand All @@ -11,7 +11,7 @@ import (
// call to Duration() it is multiplied by Factor. It is capped at
// Max. It returns to Min on every call to Reset(). Used in
// conjunction with the time package.
type backoff struct {
type Backoff struct {
attempts int
// Initial value to scale out
Initial time.Duration
Expand All @@ -23,7 +23,7 @@ type backoff struct {

// Returns the current value of the counter and then multiplies it
// Factor
func (b *backoff) Duration() (dur time.Duration) {
func (b *Backoff) Duration() (dur time.Duration) {
// Zero-values are nonsensical, so we use
// them to apply defaults
if b.Max == 0 {
Expand Down Expand Up @@ -52,6 +52,11 @@ func (b *backoff) Duration() (dur time.Duration) {
}

//Resets the current value of the counter back to Min
func (b *backoff) Reset() {
func (b *Backoff) Reset() {
b.attempts = 0
}

// Attempts returns the number of attempts that we had done so far
func (b *Backoff) Attempts() int {
return b.attempts
}
28 changes: 28 additions & 0 deletions internal/misc/misc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package misc

import (
"fmt"
"net/http"
)

// StatusCodeError represents an http response error.
// type httpStatusCode interface { HTTPStatusCode() int } to handle it.
type StatusCodeError struct {
kanata2 marked this conversation as resolved.
Show resolved Hide resolved
Code int
Status string
}

func (t StatusCodeError) Error() string {
return fmt.Sprintf("slack server error: %s", t.Status)
}

func (t StatusCodeError) HTTPStatusCode() int {
return t.Code
}

func (t StatusCodeError) Retryable() bool {
if t.Code >= 500 || t.Code == http.StatusTooManyRequests {
return true
}
return false
}
26 changes: 3 additions & 23 deletions misc.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import (
"strconv"
"strings"
"time"

"github.com/slack-go/slack/internal/misc"
)

// SlackResponse handles parsing out errors from the web api.
Expand All @@ -42,28 +44,6 @@ func (t SlackResponse) Err() error {
return errors.New(t.Error)
}

// StatusCodeError represents an http response error.
// type httpStatusCode interface { HTTPStatusCode() int } to handle it.
type statusCodeError struct {
Code int
Status string
}

func (t statusCodeError) Error() string {
return fmt.Sprintf("slack server error: %s", t.Status)
}

func (t statusCodeError) HTTPStatusCode() int {
return t.Code
}

func (t statusCodeError) Retryable() bool {
if t.Code >= 500 || t.Code == http.StatusTooManyRequests {
return true
}
return false
}

// RateLimitedError represents the rate limit respond from slack
type RateLimitedError struct {
RetryAfter time.Duration
Expand Down Expand Up @@ -312,7 +292,7 @@ func checkStatusCode(resp *http.Response, d Debug) error {
// Slack seems to send an HTML body along with 5xx error codes. Don't parse it.
if resp.StatusCode != http.StatusOK {
logResponse(resp, d)
return statusCodeError{Code: resp.StatusCode, Status: resp.Status}
return misc.StatusCodeError{Code: resp.StatusCode, Status: resp.Status}
}

return nil
Expand Down
6 changes: 4 additions & 2 deletions misc_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import (
"sync"
"testing"

"github.com/slack-go/slack/internal/misc"

"github.com/slack-go/slack/slackutilsx"
)

Expand Down Expand Up @@ -92,8 +94,8 @@ func TestParseResponseInvalidToken(t *testing.T) {
func TestRetryable(t *testing.T) {
for _, e := range []error{
&RateLimitedError{},
statusCodeError{Code: http.StatusInternalServerError},
statusCodeError{Code: http.StatusTooManyRequests},
misc.StatusCodeError{Code: http.StatusInternalServerError},
misc.StatusCodeError{Code: http.StatusTooManyRequests},
} {
r, ok := e.(slackutilsx.Retryable)
if !ok {
Expand Down
34 changes: 34 additions & 0 deletions socket_mode.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package slack

import (
"context"
)

// SocketModeConnection contains various details about the SocketMode connection.
// It is returned by an "apps.connections.open" API call.
type SocketModeConnection struct {
URL string `json:"url,omitempty"`
Data map[string]interface{} `json:"-"`
}

type openResponseFull struct {
SlackResponse
SocketModeConnection
}

// StartSocketModeContext calls the "apps.connections.open" endpoint and returns the provided URL and the full Info block with a custom context.
//
// To have a fully managed Socket Mode connection, use `socketmode.New()`, and call `Run()` on it.
func (api *Client) StartSocketModeContext(ctx context.Context) (info *SocketModeConnection, websocketURL string, err error) {
response := &openResponseFull{}
err = postJSON(ctx, api.httpclient, api.endpoint+"apps.connections.open", api.appLevelToken, nil, response, api)
if err != nil {
return nil, "", err
}

if response.Err() == nil {
api.Debugln("Using URL:", response.SocketModeConnection.URL)
}

return &response.SocketModeConnection, response.SocketModeConnection.URL, response.Err()
}
63 changes: 63 additions & 0 deletions socketmode/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package socketmode

import (
"encoding/json"
"time"

"github.com/slack-go/slack"

"github.com/gorilla/websocket"
)

type ConnectedEvent struct {
ConnectionCount int // 1 = first time, 2 = second time
Info *slack.SocketModeConnection
}

type DebugInfo struct {
// Host is the name of the host name on the Slack end, that can be something like `applink-7fc4fdbb64-4x5xq`
Host string `json:"host"`

// `hello` type only
BuildNumber int `json:"build_number"`
ApproximateConnectionTime int `json:"approximate_connection_time"`
}

type ConnectionInfo struct {
AppID string `json:"app_id"`
}

type SocketModeMessagePayload struct {
Event json.RawMessage `json:"´event"`
}

// Client is a Socket Mode client that allows programs to use [Events API](https://api.slack.com/events-api)
// and [interactive components](https://api.slack.com/interactivity) over WebSocket.
// Please see [Intro to Socket Mode](https://api.slack.com/apis/connections/socket) for more information
// on Socket Mode.
//
// The implementation is highly inspired by https://www.npmjs.com/package/@slack/socket-mode,
// but the structure and the design has been adapted as much as possible to that of our RTM client for consistency
// within the library.
//
// You can instantiate the socket mode client with
// Client's New() and call Run() to start it. Please see examples/socketmode for the usage.
type Client struct {
// Client is the main API, embedded
apiClient slack.Client

// maxPingInterval is the maximum duration elapsed after the last WebSocket PING sent from Slack
// until Client considers the WebSocket connection is dead and needs to be reopened.
maxPingInterval time.Duration

// Connection life-cycle
Events chan Event
socketModeResponses chan *Response

// dialer is a gorilla/websocket Dialer. If nil, use the default
// Dialer.
dialer *websocket.Dialer

debug bool
log ilogger
}
Loading