Files
gocryptotrader/communications/slack/slack.go
Marco Franssen 0f209165d5 Improved code quality (#154)
* Removed package-lock.json form gitignore as it ensures specific package versions

* Updated all @angular web dependencies

* Resolved tslint errors using autofix option

* Resolved some more tslint issues

* Added lint scripts to package.json to easy lint the ts files

* Updated codelyzer and tslint

* Run web on travis using node 10 and run the lint task

* Resolved some more tslint issues after upgrading tslint and codelyzer

* Resolved golint issues with regards to exchange comments

* Resolved spelling errors shown by goreportcard.com

* Resolved gofmt warnings using goreportcard.com

* Resolved golint issue by removing unrequired else statement

* Refactored slack.go to reduce cyclomatic complexity

* Fixed govet issue where Slack was passed as value instead of reference
2018-07-18 15:46:47 +10:00

405 lines
9.5 KiB
Go

// Package slack is used to connect to the slack network. Slack is a
// code-centric collaboration hub that allows users to connect via an app and
// share different types of data
package slack
import (
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"log"
"net/http"
"sync"
"time"
"github.com/gorilla/websocket"
"github.com/thrasher-/gocryptotrader/common"
"github.com/thrasher-/gocryptotrader/communications/base"
"github.com/thrasher-/gocryptotrader/config"
)
// const declares main slack url and commands that will be supported on client
// side
const (
SlackURL = "https://slack.com/api/rtm.start"
cmdStatus = "!status"
cmdHelp = "!help"
cmdSettings = "!settings"
cmdTicker = "!ticker"
cmdPortfolio = "!portfolio"
cmdOrderbook = "!orderbook"
getHelp = `GoCryptoTrader SlackBot, thank you for using this service!
Current commands are:
!status - Displays current working status of bot
!help - Displays help text
!settings - Displays current settings
!ticker - Displays recent ANX ticker
!portfolio - Displays portfolio data
!orderbook - Displays current ANX orderbook`
)
// Slack starts a websocket connection and uses https://api.slack.com/rtm real
// time messaging
type Slack struct {
base.Base
TargetChannel string
VerificationToken string
TargetChannelID string
Details Response
ReconnectURL string
WebsocketConn *websocket.Conn
Connected bool
Shutdown bool
sync.Mutex
}
// Setup takes in a slack configuration, sets bots target channel and
// sets verification token to access workspace
func (s *Slack) Setup(config config.CommunicationsConfig) {
s.Name = config.SlackConfig.Name
s.Enabled = config.SlackConfig.Enabled
s.Verbose = config.SlackConfig.Verbose
s.TargetChannel = config.SlackConfig.TargetChannel
s.VerificationToken = config.SlackConfig.VerificationToken
}
// Connect connects to the service
func (s *Slack) Connect() error {
if err := s.NewConnection(); err != nil {
return err
}
s.Connected = true
return nil
}
// PushEvent pushes an event to either a slack channel or specific client
func (s *Slack) PushEvent(base.Event) error {
return errors.New("not yet implemented")
}
// BuildURL returns an appended token string with the SlackURL
func (s *Slack) BuildURL(token string) string {
return fmt.Sprintf("%s?token=%s", SlackURL, token)
}
// GetChannelsString returns a list of all channels on the slack workspace
func (s *Slack) GetChannelsString() []string {
var channels []string
for i := range s.Details.Channels {
channels = append(channels, s.Details.Channels[i].NameNormalized)
}
return channels
}
// GetUsernameByID returns a users name by ID
func (s *Slack) GetUsernameByID(ID string) string {
for i := range s.Details.Users {
if s.Details.Users[i].ID == ID {
return s.Details.Users[i].Name
}
}
return ""
}
// GetIDByName returns either a group ID or Channel ID
func (s *Slack) GetIDByName(userName string) (string, error) {
id, err := s.GetGroupIDByName(userName)
if err != nil {
return s.GetChannelIDByName(userName)
}
return id, err
}
// GetGroupIDByName returns a groupID by group name
func (s *Slack) GetGroupIDByName(group string) (string, error) {
for i := range s.Details.Groups {
if s.Details.Groups[i].Name == group {
return s.Details.Groups[i].ID, nil
}
}
return "", errors.New("Channel not found")
}
// GetChannelIDByName returns a channel ID by its corresponding name
func (s *Slack) GetChannelIDByName(channel string) (string, error) {
for i := range s.Details.Channels {
if s.Details.Channels[i].Name == channel {
return s.Details.Channels[i].ID, nil
}
}
return "", errors.New("Channel not found")
}
// GetUsersInGroup returns a list of users currently in a group
func (s *Slack) GetUsersInGroup(group string) []string {
for i := range s.Details.Groups {
if s.Details.Groups[i].Name == group {
return s.Details.Groups[i].Members
}
}
return nil
}
// NewConnection connects the bot to a slack workgroup using a verification
// token and a channel
func (s *Slack) NewConnection() error {
err := s.SendHTTPGetRequestSlack(s.BuildURL(s.VerificationToken), true, &s.Details)
if err != nil {
return err
}
if !s.Details.Ok {
return errors.New(s.Details.Error)
}
if s.Verbose {
log.Printf("%s [%s] connected to %s [%s] \nWebsocket URL: %s.\n",
s.Details.Self.Name,
s.Details.Self.ID,
s.Details.Team.Domain,
s.Details.Team.ID,
s.Details.URL)
log.Printf("Slack channels: %s", s.GetChannelsString())
}
s.TargetChannelID, err = s.GetIDByName(s.TargetChannel)
if err != nil {
return err
}
return s.WebsocketConnect()
}
// WebsocketConnect creates a websocket dialer amd initiates a websocket
// connection
func (s *Slack) WebsocketConnect() error {
var Dialer websocket.Dialer
var err error
websocketURL := s.Details.URL
if s.ReconnectURL != "" {
websocketURL = s.ReconnectURL
}
s.WebsocketConn, _, err = Dialer.Dial(websocketURL, http.Header{})
if err != nil {
return err
}
go s.WebsocketReader()
return nil
}
// WebsocketReader reads incoming events from the websocket connection
func (s *Slack) WebsocketReader() {
for {
_, resp, err := s.WebsocketConn.ReadMessage()
if err != nil {
log.Fatal(err)
}
var data WebsocketResponse
err = common.JSONDecode(resp, &data)
if err != nil {
log.Println(err)
continue
}
switch data.Type {
case "error":
s.handleErrorResponse(data)
case "hello":
s.handleHelloResponse(data)
case "reconnect_url":
err = s.handleReconnectResponse(resp)
if err != nil {
continue
}
case "presence_change":
err = s.handlePresenceChange(resp)
if err != nil {
continue
}
case "message":
err = s.handleMessageResponse(resp, data)
if err != nil {
continue
}
case "pong":
if s.Verbose {
log.Println("Pong received from server")
}
default:
log.Println(string(resp))
}
}
}
func (s *Slack) handlePresenceChange(resp []byte) error {
var pres PresenceChange
err := common.JSONDecode(resp, &pres)
if err != nil {
return err
}
if s.Verbose {
log.Printf("Presence change. User %s [%s] changed status to %s\n",
s.GetUsernameByID(pres.User),
pres.User, pres.Presence)
}
return nil
}
func (s *Slack) handleMessageResponse(resp []byte, data WebsocketResponse) error {
if data.ReplyTo != 0 {
return fmt.Errorf("ReplyTo != 0")
}
var msg Message
err := common.JSONDecode(resp, &msg)
if err != nil {
return err
}
if s.Verbose {
log.Printf("Msg received by %s [%s] with text: %s\n",
s.GetUsernameByID(msg.User),
msg.User, msg.Text)
}
if string(msg.Text[0]) == "!" {
s.HandleMessage(msg)
}
return nil
}
func (s *Slack) handleErrorResponse(data WebsocketResponse) {
if data.Error.Msg == "Socket URL has expired" {
if s.Verbose {
log.Println("Slack websocket URL has expired.. Reconnecting")
}
if err := s.WebsocketConn.Close(); err != nil {
log.Println(err)
}
s.ReconnectURL = ""
s.Connected = false
if err := s.NewConnection(); err != nil {
log.Fatal(err)
}
return
}
}
func (s *Slack) handleHelloResponse(data WebsocketResponse) {
if s.Verbose {
log.Println("Websocket connected successfully.")
}
s.Connected = true
go s.WebsocketKeepAlive()
}
func (s *Slack) handleReconnectResponse(resp []byte) error {
type reconnectResponse struct {
URL string `json:"url"`
}
var recURL reconnectResponse
err := common.JSONDecode(resp, &recURL)
if err != nil {
return err
}
s.ReconnectURL = recURL.URL
if s.Verbose {
log.Printf("Reconnect URL set to %s\n", s.ReconnectURL)
}
return nil
}
// WebsocketKeepAlive sends a ping every 5 minutes to keep connection alive
func (s *Slack) WebsocketKeepAlive() {
ticker := time.NewTicker(5 * time.Minute)
for {
<-ticker.C
if err := s.WebsocketSend("ping", ""); err != nil {
log.Println("slack WebsocketKeepAlive() error", err)
}
}
}
// WebsocketSend sends a message via the websocket connection
func (s *Slack) WebsocketSend(eventType, text string) error {
s.Lock()
defer s.Unlock()
newMessage := SendMessage{
ID: time.Now().Unix(),
Type: eventType,
Channel: s.TargetChannelID,
Text: text,
}
data, err := json.Marshal(newMessage)
if err != nil {
return err
}
return s.WebsocketConn.WriteMessage(websocket.TextMessage, data)
}
// HandleMessage handles incoming messages and/or commands from slack
func (s *Slack) HandleMessage(msg Message) {
switch {
case common.StringContains(msg.Text, cmdStatus):
s.WebsocketSend("message", s.GetStatus())
case common.StringContains(msg.Text, cmdHelp):
s.WebsocketSend("message", getHelp)
case common.StringContains(msg.Text, cmdTicker):
s.WebsocketSend("message", s.GetTicker("ANX"))
case common.StringContains(msg.Text, cmdOrderbook):
s.WebsocketSend("message", s.GetOrderbook("ANX"))
case common.StringContains(msg.Text, cmdSettings):
s.WebsocketSend("message", s.GetSettings())
case common.StringContains(msg.Text, cmdPortfolio):
s.WebsocketSend("message", s.GetPortfolio())
default:
s.WebsocketSend("message", "GoCryptoTrader SlackBot - Command Unknown!")
}
}
// SendHTTPGetRequestSlack sends a HTTP request
func (s *Slack) SendHTTPGetRequestSlack(url string, jsonDecode bool, result interface{}) error {
res, err := http.Get(url)
if err != nil {
return err
}
httpCode := res.StatusCode
if httpCode != 200 && httpCode != 400 {
log.Printf("HTTP status code: %d\n", httpCode)
return errors.New("status code was not 200")
}
contents, err := ioutil.ReadAll(res.Body)
if err != nil {
return err
}
defer res.Body.Close()
if jsonDecode {
return common.JSONDecode(contents, &result)
}
result = string(contents)
return nil
}