github.com/sharovik/devbot@v1.0.1-0.20240308094637-4a0387c40516/internal/service/message/slack_service.go (about)

     1  package message
     2  
     3  import (
     4  	"crypto/tls"
     5  	"encoding/json"
     6  	"errors"
     7  	"net/http"
     8  	"regexp"
     9  	"strings"
    10  	"time"
    11  
    12  	"github.com/sharovik/devbot/internal/service/message/conversation"
    13  
    14  	"github.com/sharovik/devbot/internal/client"
    15  	"github.com/sharovik/devbot/internal/config"
    16  	"github.com/sharovik/devbot/internal/container"
    17  	"github.com/sharovik/devbot/internal/dto"
    18  	"github.com/sharovik/devbot/internal/log"
    19  	"github.com/sharovik/devbot/internal/service/analiser"
    20  	"golang.org/x/net/websocket"
    21  )
    22  
    23  // MsgAttributes the validation message attributes
    24  type MsgAttributes struct {
    25  	Type    string
    26  	Channel string
    27  	Text    string
    28  	User    string
    29  	BotID   string
    30  }
    31  
    32  const (
    33  	slackAPIOrigin = "https://slack.com/api"
    34  
    35  	eventTypeMessage             = "message"
    36  	eventTypeDesktopNotification = "desktop_notification"
    37  	eventTypeFileShared          = "file_shared"
    38  	eventTypeAppMention          = "app_mention"
    39  )
    40  
    41  var (
    42  	acceptedMessageTypes = map[string]string{
    43  		eventTypeMessage:             eventTypeMessage,
    44  		eventTypeDesktopNotification: eventTypeDesktopNotification,
    45  		eventTypeFileShared:          eventTypeFileShared,
    46  		eventTypeAppMention:          eventTypeAppMention,
    47  	}
    48  )
    49  
    50  // SlackService struct of message service
    51  type SlackService struct {
    52  }
    53  
    54  func (SlackService) fetchMainChannelID() error {
    55  	availableChannels, statusCode, err := container.C.MessageClient.GetConversationsList()
    56  	if err != nil {
    57  		log.Logger().AddError(err).Int("status_code", statusCode).Msg("Failed conversations list fetching")
    58  		return err
    59  	}
    60  
    61  	var generalChannel dto.Channel
    62  	for _, channel := range availableChannels.Channels {
    63  		if channel.Name == container.C.Config.MessagesAPIConfig.MainChannelAlias {
    64  			generalChannel = channel
    65  			break
    66  		}
    67  	}
    68  
    69  	if container.C.Config.MessagesAPIConfig.MainChannelID == "" {
    70  		if err := container.C.Config.SetToEnv(config.EnvMainChannelID, generalChannel.ID, true); err != nil {
    71  			log.Logger().AddError(err).Str("channel_id", generalChannel.ID).Msg("Failed to save slackEnvMainChannelID in .env file")
    72  			return err
    73  		}
    74  
    75  		container.C.Config.MessagesAPIConfig.MainChannelID = generalChannel.ID
    76  	}
    77  
    78  	return nil
    79  }
    80  
    81  func (SlackService) fetchBotUserID() error {
    82  	availableUsers, statusCode, err := container.C.MessageClient.GetUsersList()
    83  	if err != nil {
    84  		log.Logger().AddError(err).Int("status_code", statusCode).Msg("Failed conversations list fetching")
    85  		return err
    86  	}
    87  
    88  	var botMember dto.SlackMember
    89  	for _, member := range availableUsers.Members {
    90  		if member.Profile.RealName == container.C.Config.MessagesAPIConfig.BotName {
    91  			botMember = member
    92  			break
    93  		}
    94  	}
    95  
    96  	if container.C.Config.MessagesAPIConfig.BotUserID == "" {
    97  		if err := container.C.Config.SetToEnv(config.EnvUserID, botMember.ID, true); err != nil {
    98  			log.Logger().AddError(err).Str("user_id", botMember.ID).Msg("Failed to save slackEnvMainChannelID in .env file")
    99  			return err
   100  		}
   101  
   102  		container.C.Config.MessagesAPIConfig.BotUserID = botMember.ID
   103  	}
   104  
   105  	return nil
   106  }
   107  
   108  // BeforeWSConnectionStart runs methods before the WS connection start
   109  func (s SlackService) BeforeWSConnectionStart() error {
   110  	if container.C.Config.MessagesAPIConfig.MainChannelID == "" {
   111  		log.Logger().Info().Msg("Main channel ID wasn't specified. Trying to fetch main channel from API")
   112  		if err := s.fetchMainChannelID(); err != nil {
   113  			log.Logger().AddError(err).Msg("Failed to fetch channels")
   114  			return err
   115  		}
   116  	}
   117  
   118  	if container.C.Config.MessagesAPIConfig.BotUserID == "" {
   119  		log.Logger().Info().Msg("Bot user ID wasn't specified. Trying to fetch user ID from API")
   120  		if err := s.fetchBotUserID(); err != nil {
   121  			log.Logger().AddError(err).Msg("Failed to fetch user ID")
   122  			return err
   123  		}
   124  	}
   125  
   126  	log.Logger().AppendGlobalContext(map[string]interface{}{
   127  		"main_channel_id":    container.C.Config.MessagesAPIConfig.MainChannelID,
   128  		"main_channel_alias": container.C.Config.MessagesAPIConfig.MainChannelAlias,
   129  		"bot_user_id":        container.C.Config.MessagesAPIConfig.BotUserID,
   130  		"bot_user_name":      container.C.Config.MessagesAPIConfig.BotName,
   131  	})
   132  
   133  	return nil
   134  }
   135  
   136  // InitWebSocketReceiver method for initialization of websocket receiver
   137  func (s SlackService) InitWebSocketReceiver() error {
   138  	if err := s.BeforeWSConnectionStart(); err != nil {
   139  		log.Logger().AddError(err).Msg("Failed to prepare service for WS connection")
   140  		return err
   141  	}
   142  
   143  	ws, statusCode, err := s.wsConnect()
   144  	if err != nil {
   145  		log.Logger().AddError(err).Int("status_code", statusCode).Msg("Failed connect to the websocket")
   146  		return err
   147  	}
   148  
   149  	var (
   150  		event interface{}
   151  	)
   152  
   153  	for {
   154  		var message dto.SlackResponseEventAPIMessage
   155  
   156  		//Receive message
   157  		if err = websocket.JSON.Receive(ws, &event); err != nil {
   158  			log.Logger().AddError(err).Msg("Something went wrong with message receiving from EventsAPI")
   159  			return err
   160  		}
   161  
   162  		str, _ := json.Marshal(&event)
   163  		if strings.Contains(string(str), `"channel":{"created"`) || strings.Contains(string(str), `"type":"user_change"`) {
   164  			log.Logger().Debug().RawJSON("message_body", str).Msg("Received not supported event. Ignoring.")
   165  			continue
   166  		}
   167  
   168  		if err = json.Unmarshal(str, &message); err != nil {
   169  			log.Logger().AddError(err).
   170  				RawJSON("message_body", str).
   171  				Msg("Something went wrong with message parsing")
   172  			return err
   173  		}
   174  
   175  		if message.Type == "hello" {
   176  			continue
   177  		}
   178  
   179  		log.Logger().Debug().
   180  			RawJSON("message_body", str).
   181  			Str("envelope_id", message.EnvelopeID).
   182  			Msg("Received event message")
   183  
   184  		if err = acknowledge(ws, message.EnvelopeID); err != nil {
   185  			log.Logger().AddError(err).
   186  				RawJSON("message_body", str).
   187  				Str("envelope_id", message.EnvelopeID).
   188  				Msg("Failed to acknowledge the message")
   189  
   190  			return err
   191  		}
   192  
   193  		if !isValidMessage(MsgAttributes{
   194  			Type:    message.Payload.Event.Type,
   195  			Channel: message.Payload.Event.Channel,
   196  			Text:    message.Payload.Event.Text,
   197  			User:    message.Payload.Event.User,
   198  			BotID:   message.Payload.Event.BotID,
   199  		}) {
   200  			continue
   201  		}
   202  
   203  		if err = s.ProcessMessage(&message); err != nil {
   204  			log.Logger().AddError(err).Interface("message_object", &message).Msg("Can't check or answer to the message")
   205  		}
   206  
   207  		conversation.CleanUpExpiredMessages()
   208  	}
   209  }
   210  
   211  func acknowledge(ws *websocket.Conn, envelopeID string) error {
   212  	res := dto.SlackRequestAcknowledge{
   213  		EnvelopeID: envelopeID,
   214  	}
   215  
   216  	log.Logger().Debug().
   217  		Str("envelope_id", envelopeID).
   218  		Msg("Acknowledge event message")
   219  
   220  	return websocket.JSON.Send(ws, res)
   221  }
   222  
   223  // ProcessMessage processes the message from the WS connection
   224  func (s SlackService) ProcessMessage(msg interface{}) error {
   225  	message := msg.(*dto.SlackResponseEventAPIMessage)
   226  	log.Logger().Debug().
   227  		Str("type", message.Type).
   228  		Str("text", message.Payload.Event.Text).
   229  		Str("team", message.Payload.Event.Team).
   230  		Str("ts", message.Payload.Event.Ts).
   231  		Str("user", message.Payload.Event.User).
   232  		Str("channel", message.Payload.Event.Channel).
   233  		Msg("Message received")
   234  
   235  	//We need to trim the message before all checks
   236  	message.Payload.Event.Text = strings.TrimSpace(message.Payload.Event.Text)
   237  
   238  	dmAnswer, err := analiser.GetDmAnswer(analiser.Message{
   239  		Channel: message.Payload.Event.Channel,
   240  		User:    message.Payload.Event.User,
   241  		Text:    message.Payload.Event.Text,
   242  	})
   243  	if err != nil {
   244  		log.Logger().AddError(err).Msg("Failed to get dictionary message answer")
   245  		return err
   246  	}
   247  
   248  	m, err := prepareAnswer(&dto.SlackResponseEventMessage{
   249  		Channel:      message.Payload.Event.Channel,
   250  		ClientMsgID:  message.Payload.Event.ClientMsgID,
   251  		DisplayAsBot: false,
   252  		EventTs:      message.Payload.Event.EventTs,
   253  		ThreadTS:     message.Payload.Event.ThreadTS,
   254  		Files:        nil,
   255  		Team:         message.Payload.Event.Team,
   256  		Text:         message.Payload.Event.Text,
   257  		Ts:           message.Payload.Event.Ts,
   258  		Type:         message.Payload.Event.Type,
   259  		User:         message.Payload.Event.User,
   260  	}, dmAnswer)
   261  	if err != nil {
   262  		log.Logger().AddError(err).Msg("Failed to analyse received message")
   263  		return err
   264  	}
   265  
   266  	emptyDmMessage := dto.DictionaryMessage{}
   267  	if dmAnswer == emptyDmMessage {
   268  		log.Logger().Debug().
   269  			Str("type", message.Payload.Event.Type).
   270  			Str("text", message.Payload.Event.Text).
   271  			Str("team", message.Payload.Event.Team).
   272  			Str("ts", message.Payload.Event.Ts).
   273  			Str("user", message.Payload.Event.User).
   274  			Str("channel", message.Payload.Event.Channel).
   275  			Msg("No answer found for the received message")
   276  	} else {
   277  		//We put a dictionary message into our message object,
   278  		// so later we can identify what kind of reaction will be executed
   279  		m.DictionaryMessage = dmAnswer
   280  	}
   281  
   282  	if err = TriggerAnswer(message.Payload.Event.Channel, m, true); err != nil {
   283  		log.Logger().AddError(err).Msg("Failed trigger the answer")
   284  		return err
   285  	}
   286  
   287  	refreshPreparedMessages()
   288  	return nil
   289  }
   290  
   291  func getWSClient() client.SlackClient {
   292  	netTransport := &http.Transport{
   293  		TLSHandshakeTimeout: time.Duration(container.C.Config.HTTPClient.TLSHandshakeTimeout) * time.Second,
   294  		TLSClientConfig: &tls.Config{
   295  			InsecureSkipVerify: container.C.Config.HTTPClient.InsecureSkipVerify,
   296  		},
   297  	}
   298  
   299  	httpClient := http.Client{
   300  		Timeout:   time.Duration(container.C.Config.HTTPClient.RequestTimeout) * time.Second,
   301  		Transport: netTransport,
   302  	}
   303  
   304  	c := client.HTTPClient{
   305  		Client: &httpClient,
   306  	}
   307  
   308  	c.SetOauthToken(container.C.Config.MessagesAPIConfig.OAuthToken)
   309  	c.SetBaseURL(container.C.Config.MessagesAPIConfig.BaseURL)
   310  
   311  	sc := client.SlackClient{}
   312  	sc.HTTPClient = &c
   313  
   314  	return sc
   315  }
   316  
   317  // wsConnect method for receiving of websocket URL which we will use for our connection
   318  func (SlackService) wsConnect() (*websocket.Conn, int, error) {
   319  	response, statusCode, err := getWSClient().HTTPClient.Post("/apps.connections.open", []byte{}, map[string]string{})
   320  	if err != nil {
   321  		log.Logger().AddError(err).RawJSON("response", response).Int("status_code", statusCode).Msg("Failed send message")
   322  		return &websocket.Conn{}, statusCode, err
   323  	}
   324  
   325  	var dtoResponse dto.SlackResponseRTMConnect
   326  	if err := json.Unmarshal(response, &dtoResponse); err != nil {
   327  		return &websocket.Conn{}, statusCode, err
   328  	}
   329  
   330  	if !dtoResponse.Ok {
   331  		return &websocket.Conn{}, statusCode, errors.New(dtoResponse.Error)
   332  	}
   333  
   334  	ws, err := websocket.Dial(dtoResponse.URL, "", slackAPIOrigin)
   335  
   336  	return ws, statusCode, err
   337  }
   338  
   339  func isValidMessage(msg MsgAttributes) bool {
   340  	if acceptedMessageTypes[msg.Type] == "" {
   341  		log.Logger().Debug().Str("type", msg.Type).Msg("Skip message check for this message type")
   342  		return false
   343  	}
   344  
   345  	if msg.Channel == "" {
   346  		log.Logger().Debug().Msg("Message channel cannot be empty")
   347  		return false
   348  	}
   349  
   350  	if isGlobalAlertTriggered(msg.Text) {
   351  		log.Logger().Debug().Msg("The global alert is triggered. Skipping.")
   352  		return false
   353  	}
   354  
   355  	if msg.User == container.C.Config.MessagesAPIConfig.BotUserID || msg.BotID != "" {
   356  		log.Logger().Debug().Msg("This message is from our bot user")
   357  		return false
   358  	}
   359  
   360  	if msg.Text == "" {
   361  		log.Logger().Debug().Msg("This message has empty text. Skipping.")
   362  		return false
   363  	}
   364  
   365  	return true
   366  }
   367  
   368  func isGlobalAlertTriggered(text string) bool {
   369  	re, err := regexp.Compile(`(?i)(<!(here|channel)>)`)
   370  	if err != nil {
   371  		log.Logger().AddError(err).Msg("Failed to parse global alert text part")
   372  		return false
   373  	}
   374  
   375  	return re.MatchString(text)
   376  }