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 22 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
80 changes: 80 additions & 0 deletions examples/socketmode/socketmode.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package main

import (
"fmt"
"github.com/slack-go/slack/slacksocketmode"
kanata2 marked this conversation as resolved.
Show resolved Hide resolved
"log"
"os"
"strings"

"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_APP_TOKEN must have the prefix \"xoxb-\".")
}

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

client := slacksocketmode.New(api)

go func() {
for evt := range client.IncomingEvents {
eventsAPIEvent, ok := evt.Data.(slackevents.EventsAPIEvent)
if !ok {
fmt.Printf("Ignored %+v\n", evt)

continue
}

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

switch evt.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.MemberJoinedChannel:
ev := eventsAPIEvent.Data.(*slackevents.MemberJoinedChannelEvent)

fmt.Printf("user %q joined to channel %q", ev.User, ev.Channel)
case slackevents.AppMention:
ev := eventsAPIEvent.Data.(*slackevents.AppMentionEvent)

_, _, err := api.PostMessage(ev.Channel, slack.MsgOptionText("hey yo!", false))
if err != nil {
fmt.Printf("failed posting message: %v", err)
}
}
}
}()

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
}
29 changes: 29 additions & 0 deletions internal/misc/misc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
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
}

25 changes: 2 additions & 23 deletions misc.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"encoding/json"
"errors"
"fmt"
"github.com/slack-go/slack/internal/misc"
"io"
"io/ioutil"
"mime"
Expand Down Expand Up @@ -42,28 +43,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 +291,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
5 changes: 3 additions & 2 deletions misc_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package slack

import (
"context"
"github.com/slack-go/slack/internal/misc"
"log"
"net/http"
"net/url"
Expand Down Expand Up @@ -92,8 +93,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
118 changes: 118 additions & 0 deletions slacksocketmode/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package slacksocketmode

import (
"encoding/json"
"github.com/slack-go/slack"
"net/url"
"sync"
"time"

"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"`
}

// ClientEvent is the event sent to the consumer of Client
type ClientEvent struct {
Type string
Data interface{}

// Request is the json-decoded raw WebSocket message that is received via the Slack Socket Mode
// WebSocket connection.
Request *Request
}

// Client allows allows programs to communicate with the
// [Events API](https://api.slack.com/events-api) over WebSocket.
//
// 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() or NewSocketModeClientWithOptions(*SocketModeClientOptions)
type Client struct {
// Client is the main API, embedded
slack.Client

idGen slack.IDGenerator
pingInterval time.Duration

// pingDeadman must be intiailized in New()
pingDeadman *time.Timer

// Connection life-cycle
conn *websocket.Conn
IncomingEvents chan ClientEvent
socketModeResponses chan *Response
killChannel chan bool
disconnected chan struct{}
disconnectedm *sync.Once

// UserDetails upon connection
info *slack.SocketModeConnection

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

// mu is mutex used to prevent RTM connection race conditions
mu *sync.Mutex

wsWriteMu *sync.Mutex

// connParams is a map of flags for connection parameters.
connParams url.Values
}

// signal that we are disconnected by closing the channel.
// protect it with a mutex to ensure it only happens once.
func (smc *Client) disconnect() {
smc.disconnectedm.Do(func() {
close(smc.disconnected)
})
}

// Disconnect and wait, blocking until a successful disconnection.
func (smc *Client) Disconnect() error {
// always push into the kill channel when invoked,
// this lets the ManagedConnection() function properly clean up.
// if the buffer is full then just continue on.
select {
case smc.killChannel <- true:
return nil
case <-smc.disconnected:
return slack.ErrAlreadyDisconnected
}
}

// GetInfo returns the info structure received when calling
// "startrtm", holding metadata needed to implement a full
// chat client. It will be non-nil after a call to StartRTM().
func (smc *Client) GetInfo() *slack.SocketModeConnection {
return smc.info
}

func (smc *Client) resetDeadman() {
smc.pingDeadman.Reset(deadmanDuration(smc.pingInterval))
}
7 changes: 7 additions & 0 deletions slacksocketmode/deadman.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package slacksocketmode

import "time"

func deadmanDuration(d time.Duration) time.Duration {
return d * 4
}
6 changes: 6 additions & 0 deletions slacksocketmode/event.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package slacksocketmode

type ErrorWriteFailed struct {
Cause error
Response *Response
}
38 changes: 38 additions & 0 deletions slacksocketmode/request.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package slacksocketmode

import "encoding/json"

// Request maps to the content of each WebSocket message received via a Socket Mode WebSocket connection
//
// We call this a "request" rather than e.g. a WebSocket message or an Socket Mode "event" following python-slack-sdk:
//
// https://github.com/slackapi/python-slack-sdk/blob/3f1c4c6e27bf7ee8af57699b2543e6eb7848bcf9/slack_sdk/socket_mode/request.py#L6
//
// We know that node-slack-sdk calls it an "event", that makes it hard for us to distinguish our client's own event
// that wraps both internal events and Socket Mode "events", vs node-slack-sdk's is for the latter only.
//
// https://github.com/slackapi/node-slack-sdk/blob/main/packages/socket-mode/src/SocketModeClient.ts#L537
type Request struct {
Type string `json:"type"`

// `hello` type only
NumConnections int `json:"num_connections"`
ConnectionInfo ConnectionInfo `json:"connection_info"`

// `disconnect` type only

// Reason can be "warning" or else
Reason string `json:"reason"`

// `hello` and `disconnect` types only
DebugInfo DebugInfo `json:"debug_info"`

// `events_api` type only
EnvelopeID string `json:"envelope_id"`
// TODO Can it really be a non-object type?
// See https://github.com/slackapi/python-slack-sdk/blob/3f1c4c6e27bf7ee8af57699b2543e6eb7848bcf9/slack_sdk/socket_mode/request.py#L26-L31
Payload json.RawMessage `json:"payload"`
AcceptsResponsePayload bool `json:"accepts_response_payload"`
RetryAttempt int `json:"retry_attempt"`
RetryReason string `json:"retry_reason"`
}
Loading