-
Notifications
You must be signed in to change notification settings - Fork 57
/
helper.go
692 lines (574 loc) · 22.4 KB
/
helper.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
// SPDX-License-Identifier: Apache-2.0
package slack
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
"github.com/gorilla/mux"
"github.com/rs/zerolog/log"
"github.com/slack-go/slack"
"github.com/slack-go/slack/slackevents"
"github.com/slack-go/slack/socketmode"
"github.com/target/flottbot/models"
)
/*
======================================================================
Slack helper functions (anything that uses the 'slack-go/slack' package)
======================================================================
*/
// getEventsAPIHealthHandler creates and returns the handler for health checks on the Slack Events API reader.
func getEventsAPIHealthHandler(_ *models.Bot) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
log.Error().Msgf("received invalid method: %s", r.Method)
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
log.Debug().Msg("bot event health endpoint hit")
w.WriteHeader(http.StatusOK)
_, err := w.Write([]byte("OK"))
if err != nil {
log.Error().Msgf("failed to send health response: %v", err)
}
}
}
func sendHTTPResponse(status int, message string, w http.ResponseWriter) {
w.WriteHeader(status)
w.Header().Set("Content-Type", "text/plain")
_, err := w.Write([]byte(message))
if err != nil {
log.Error().Msgf("failed to send response: %v", err)
}
}
func handleURLVerification(body []byte, w http.ResponseWriter) {
var slackResponse *slackevents.ChallengeResponse
statusCode := http.StatusOK
err := json.Unmarshal(body, &slackResponse)
if err != nil {
statusCode = http.StatusInternalServerError
}
sendHTTPResponse(statusCode, slackResponse.Challenge, w)
}
func handleCallBack(api *slack.Client, event slackevents.EventsAPIInnerEvent, bot *models.Bot, inputMsgs chan<- models.Message, w http.ResponseWriter) {
// write back to the event to ensure the event does not trigger again
sendHTTPResponse(http.StatusOK, "{}", w)
// process the event
log.Info().Msgf("received event: %s", event.Type)
switch ev := event.Data.(type) {
// Ignoring app_mention events
case *slackevents.AppMentionEvent:
// There are Events API specific MessageEvents
// https://api.slack.com/events/message.channels
case *slackevents.MessageEvent:
senderID := ev.User
// check if message originated from a bot
// and whether we should respond to other bot messages
if ev.BotID != "" && bot.RespondToBots {
// get bot information to get
// the associated user id
user, err := api.GetBotInfo(ev.BotID)
if err != nil {
log.Error().Msgf("unable to retrieve bot info for %#q", ev.BotID)
return
}
// use the bot's user id as the senderID
senderID = user.UserID
}
// only process messages that aren't from our bot
if senderID != "" && bot.ID != senderID {
channel := ev.Channel
msgType, err := getMessageType(channel)
if err != nil {
log.Error().Msg(err.Error())
}
text, mentioned := removeBotMention(ev.Text, bot.ID)
// get the full user object for the given ID
user, err := api.GetUserInfo(senderID)
if err != nil {
log.Error().Msgf("error getting slack user info: %v", err)
}
timestamp := ev.TimeStamp
threadTimestamp := ev.ThreadTimeStamp
// get the link to the message, will be empty string if there's an error
link, err := api.GetPermalink(&slack.PermalinkParameters{Channel: channel, Ts: timestamp})
if err != nil {
log.Error().Msgf("unable to retrieve link to message: %#q", err.Error())
}
inputMsgs <- populateMessage(models.NewMessage(), msgType, channel, text, timestamp, threadTimestamp, link, mentioned, user, bot)
}
case *slackevents.MemberJoinedChannelEvent:
// limit to our bot
if ev.User == bot.ID {
// options for getting channel info
opts := &slack.GetConversationInfoInput{
ChannelID: ev.Channel,
IncludeLocale: false,
IncludeNumMembers: true,
}
// look up channel info, since 'ev' only gives us ID
channel, err := api.GetConversationInfo(opts)
if err != nil {
log.Error().Msgf("unable to fetch channel info for channel joined event: %v", err)
}
// add the room to the lookup
bot.Rooms[channel.Name] = channel.ID
log.Info().Msgf("joined new channel - %s (%s) added to lookup", channel.Name, channel.ID)
}
default:
log.Debug().Msgf("unrecognized event type: %v", ev)
}
}
// getEventsAPIEventHandler creates and returns the handler for events coming from the the Slack Events API reader.
func getEventsAPIEventHandler(api *slack.Client, signingSecret string, inputMsgs chan<- models.Message, bot *models.Bot) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
// silently throw away anything that's not a POST
if r.Method != http.MethodPost {
log.Error().Msg("slack: method not allowed")
sendHTTPResponse(http.StatusMethodNotAllowed, "method not allowed", w)
return
}
// read in the body of the incoming payload
body, err := io.ReadAll(r.Body)
if err != nil {
log.Error().Msg("slack: error reading request body")
sendHTTPResponse(http.StatusBadRequest, "error reading request body", w)
return
}
// create a new secrets verifier with
// the request header and signing secret
sv, err := slack.NewSecretsVerifier(r.Header, signingSecret)
if err != nil {
log.Error().Msg("slack: error creating secrets verifier")
sendHTTPResponse(http.StatusBadRequest, "error creating secrets verifier", w)
return
}
// write the request body's hash
if _, err := sv.Write(body); err != nil {
log.Error().Msg("slack: error while writing body")
sendHTTPResponse(http.StatusInternalServerError, "error while writing body", w)
return
}
// validate signing secret with computed hash
if err := sv.Ensure(); err != nil {
log.Error().Msg("slack: request unauthorized")
sendHTTPResponse(http.StatusUnauthorized, "request unauthorized", w)
return
}
// parse the event from the request
eventsAPIEvent, err := slackevents.ParseEvent(json.RawMessage(body), slackevents.OptionNoVerifyToken())
if err != nil {
log.Error().Msg("slack: error while parsing event")
sendHTTPResponse(http.StatusInternalServerError, "error while parsing event", w)
return
}
// validate a URLVerification event with signing secret
if eventsAPIEvent.Type == slackevents.URLVerification {
log.Debug().Msg("slack: received slack challenge request - sending challenge response...")
handleURLVerification(body, w)
}
// process regular Callback events
if eventsAPIEvent.Type == slackevents.CallbackEvent {
handleCallBack(api, eventsAPIEvent.InnerEvent, bot, inputMsgs, w)
}
}
}
// getRooms - return a map of rooms.
func getRooms(api *slack.Client) map[string]string {
rooms := make(map[string]string)
// we're getting all channel types by default
// this can be controlled with permission scopes in Slack:
// channels:read, groups:read, im:read, mpim:read
cp := slack.GetConversationsParameters{
Cursor: "",
ExcludeArchived: true,
Limit: 1000, // this is the maximum value allowed
Types: []string{"public_channel", "private_channel", "mpim", "im"},
}
// there's a possibility we need to page through results
// the results, so we're looping until there are no more pages
for {
channels, nc, err := api.GetConversations(&cp)
if err != nil {
break
}
// populate our channel map
for _, channel := range channels {
rooms[channel.Name] = channel.ID
}
// no more pages to process? quit the loop
if len(nc) == 0 {
break
}
// override the cursor
cp.Cursor = nc
}
return rooms
}
// getSlackUsers gets Slack user objects for each user listed in messages 'output_to_users' field.
func getSlackUsers(api *slack.Client, message models.Message) ([]slack.User, error) {
slackUsers := []slack.User{}
// grab list of users to message if 'output_to_users' was specified
if len(message.OutputToUsers) > 0 {
start := time.Now()
res, err := api.GetUsers()
if err != nil {
return []slack.User{}, fmt.Errorf("did not find any users listed in 'output_to_users': %w", err)
}
log.Info().Msgf("fetched %d users in %s", len(res), time.Since(start).String())
slackUsers = res
}
return slackUsers, nil
}
// getUserID - returns the user's Slack user ID via email.
func getUserID(email string, users []slack.User) string {
email = strings.ToLower(email)
for _, u := range users {
if strings.Contains(strings.ToLower(u.Profile.Email), email) {
return u.ID
}
}
log.Error().Msgf("could not find user %#q", email)
return ""
}
// handleDirectMessage - handle sending logic for direct messages.
func handleDirectMessage(api *slack.Client, message models.Message) error {
// Is output to rooms set?
if len(message.OutputToRooms) > 0 {
log.Warn().Msg("you have specified 'direct_message_only' as 'true' and provided 'output_to_rooms' -" +
" messages will not be sent to listed rooms - if you want to send messages to these rooms," +
" please set 'direct_message_only' to 'false'")
}
// Is output to users set?
if len(message.OutputToUsers) > 0 {
log.Warn().Msg("you have specified 'direct_message_only' as 'true' and provided 'output_to_users' -" +
" messages will not be sent to the listed users (other than you) - if you want to send messages to other users," +
" please set 'direct_message_only' to 'false'")
}
// Respond back to user via direct message
return sendDirectMessage(api, message.Vars["_user.id"], message)
}
// handleNonDirectMessage - handle sending logic for non direct messages.
func handleNonDirectMessage(api *slack.Client, users []slack.User, message models.Message) error {
// 'direct_message_only' is either 'false' OR
// 'direct_message_only' was probably never set
// Is output to rooms set?
if len(message.OutputToRooms) > 0 {
for _, roomID := range message.OutputToRooms {
err := sendChannelMessage(api, roomID, message)
if err != nil {
return err
}
}
}
// Is output to users set?
if len(message.OutputToUsers) > 0 {
// this assumes output to users is an email?? that's mentioned nowhere
for _, u := range message.OutputToUsers {
// Get users Slack user ID based on username supplied in output to users
// which assumes that email follows certain pattern, ie. firstname.lastname -> firstname.lastname@example.com
// note: requires email scope
userID := getUserID(u, users)
if userID != "" {
// If 'direct_message_only' is 'false' but the user listed himself in the 'output_to_users'
if userID == message.Vars["_user.id"] && !message.DirectMessageOnly {
log.Warn().Msg("you have specified 'direct_message_only' as 'false' but listed yourself in 'output_to_users'")
}
// Respond back to these users via direct message
err := sendDirectMessage(api, userID, message)
if err != nil {
return err
}
}
}
}
// Was there no specified output set?
// Send message back to original channel
if len(message.OutputToRooms) == 0 && len(message.OutputToUsers) == 0 {
err := sendBackToOriginMessage(api, message)
if err != nil {
return err
}
}
return nil
}
// populateUserGroups populates slack user groups.
func populateUserGroups(sug []slack.UserGroup, bot *models.Bot) {
userGroups := make(map[string]string)
// create a map of usergroups
for _, usergroup := range sug {
userGroups[usergroup.Handle] = usergroup.ID
}
// add usergroups to bot
bot.UserGroups = userGroups
}
// populateMessage - populates the 'Message' object to be passed on for processing/sending.
func populateMessage(message models.Message, msgType models.MessageType, channel, text, timeStamp, threadTimestamp, link string, mentioned bool, user *slack.User, bot *models.Bot) models.Message {
switch msgType {
case models.MsgTypeDirect, models.MsgTypeChannel, models.MsgTypePrivateChannel:
// Populate message attributes
message.Type = msgType
message.Service = models.MsgServiceChat
message.ChannelID = channel
message.Input = text
message.Output = ""
message.Timestamp = timeStamp
message.ThreadTimestamp = threadTimestamp
message.BotMentioned = mentioned
message.SourceLink = link
// If the message read was not a dm, get the name of the channel it came from
if msgType != models.MsgTypeDirect {
name, ok := findKey(bot.Rooms, channel)
if !ok {
log.Error().Msgf("could not find name of channel %#q", channel)
}
message.ChannelName = name
}
// make channel variables available
message.Vars["_channel.id"] = message.ChannelID
message.Vars["_channel.name"] = message.ChannelName // will be empty if it came via DM
// make link to trigger message available
message.Vars["_source.link"] = message.SourceLink
// make timestamp information available
message.Vars["_source.timestamp"] = timeStamp
message.Vars["_source.thread_timestamp"] = threadTimestamp
// Populate message with user information (i.e. who sent the message)
// These will be accessible on rules via ${_user.email}, ${_user.id}, etc.
if user != nil { // nil user implies a message from an api/bot (i.e. not an actual user)
message.Vars["_user.id"] = user.ID
message.Vars["_user.teamid"] = user.TeamID
message.Vars["_user.name"] = user.Name
message.Vars["_user.color"] = user.Color
message.Vars["_user.realname"] = user.RealName
message.Vars["_user.tz"] = user.TZ
message.Vars["_user.tzlabel"] = user.TZLabel
message.Vars["_user.tzoffset"] = strconv.Itoa(user.TZOffset)
message.Vars["_user.firstname"] = user.Profile.FirstName
message.Vars["_user.lastname"] = user.Profile.LastName
message.Vars["_user.realnamenormalized"] = user.Profile.RealNameNormalized
message.Vars["_user.displayname"] = user.Profile.DisplayName
message.Vars["_user.displaynamenormalized"] = user.Profile.DisplayNameNormalized
message.Vars["_user.email"] = user.Profile.Email
message.Vars["_user.skype"] = user.Profile.Skype
message.Vars["_user.phone"] = user.Profile.Phone
message.Vars["_user.title"] = user.Profile.Title
message.Vars["_user.statustext"] = user.Profile.StatusText
message.Vars["_user.statusemoji"] = user.Profile.StatusEmoji
message.Vars["_user.team"] = user.Profile.Team
}
return message
default:
log.Debug().Msgf("read message of unsupported type '%T' - unable to populate message attributes", msgType)
return message
}
}
// readFromEventsAPI utilizes the Slack API client to read event-based messages.
// This method of reading is preferred over the RTM method.
func readFromEventsAPI(api *slack.Client, vToken string, inputMsgs chan<- models.Message, bot *models.Bot) {
// populate user groups
go getUserGroups(api, bot)
// Create router for the events server
router := mux.NewRouter()
// Add health check handler
router.HandleFunc("/event_health", getEventsAPIHealthHandler(bot)).Methods("GET")
// Add event handler
router.HandleFunc(bot.SlackEventsCallbackPath, getEventsAPIEventHandler(api, vToken, inputMsgs, bot)).Methods("POST")
// Start listening to Slack events
maskedPort := fmt.Sprintf(":%s", bot.SlackListenerPort)
go func() {
//nolint:gosec // fix to use server with timeout
err := http.ListenAndServe(maskedPort, router)
if err != nil {
log.Fatal().Msg("failed to run server")
}
}()
log.Info().Msgf("slack events api server is listening to %#q on port %#q",
bot.SlackEventsCallbackPath, bot.SlackListenerPort)
}
// readFromSocketMode reads messages from Slack's Socket Mode
//
// https://api.slack.com/apis/connections/socket
//
//nolint:gocyclo // needs refactor
func readFromSocketMode(sm *slack.Client, inputMsgs chan<- models.Message, bot *models.Bot) {
// setup the client
client := socketmode.New(sm)
// spawn anonymous goroutine
go func() {
for evt := range client.Events {
switch evt.Type {
case socketmode.EventTypeHello:
// handle "hello" event
continue
case socketmode.EventTypeEventsAPI:
eventsAPIEvent, ok := evt.Data.(slackevents.EventsAPIEvent)
if !ok {
log.Error().Msgf("ignored: %+v", evt)
continue
}
log.Debug().Msgf("event received: %+v", eventsAPIEvent)
// acknowledge event to Slack
client.Ack(*evt.Request)
switch eventsAPIEvent.Type {
case slackevents.CallbackEvent:
innerEvent := eventsAPIEvent.InnerEvent
switch ev := innerEvent.Data.(type) {
case *slackevents.AppMentionEvent, *slackevents.ReactionAddedEvent:
continue
case *slackevents.MessageEvent:
senderID := ev.User
// check if message originated from a bot
// and whether we should respond to other bot messages
if ev.BotID != "" && bot.RespondToBots {
// get bot information to get
// the associated user id
user, err := sm.GetBotInfo(ev.BotID)
if err != nil {
log.Error().Msgf("unable to retrieve bot info for %#q", ev.BotID)
return
}
// use the bot's user id as the senderID
senderID = user.UserID
}
// only process message that are not from our bot
if senderID != "" && bot.ID != senderID {
channel := ev.Channel
// determine the message type
msgType, err := getMessageType(channel)
if err != nil {
log.Error().Msg(err.Error())
}
// remove the bot mention from the user input
text, mentioned := removeBotMention(ev.Text, bot.ID)
// get information on the user
user, err := sm.GetUserInfo(senderID)
if err != nil {
log.Error().Msgf("did not get slack user info: %s", err.Error())
}
timestamp := ev.TimeStamp
threadTimestamp := ev.ThreadTimeStamp
// get the link to the message, will be empty string if there's an error
link, err := sm.GetPermalink(&slack.PermalinkParameters{Channel: channel, Ts: timestamp})
if err != nil {
log.Error().Msgf("unable to retrieve link to message: %s", err.Error())
}
inputMsgs <- populateMessage(models.NewMessage(), msgType, channel, text, timestamp, threadTimestamp, link, mentioned, user, bot)
}
case *slackevents.MemberJoinedChannelEvent:
// limit to our bot
if ev.User == bot.ID {
// options for getting channel info
opts := &slack.GetConversationInfoInput{
ChannelID: ev.Channel,
IncludeLocale: false,
IncludeNumMembers: true,
}
// look up channel info, since 'ev' only gives us ID
channel, err := sm.GetConversationInfo(opts)
if err != nil {
log.Error().Msgf("unable to fetch channel info for channel joined event: %v", err)
}
// add the room to the lookup
bot.Rooms[channel.Name] = channel.ID
log.Info().Msgf("joined new channel - %s (%s) added to lookup", channel.Name, channel.ID)
}
}
default:
log.Warn().Msgf("unsupported events api event received: %s", eventsAPIEvent.Type)
}
case socketmode.EventTypeConnecting:
log.Info().Msg("connecting to slack via socket mode...")
case socketmode.EventTypeConnectionError:
log.Error().Msg("connection failed - retrying later...")
case socketmode.EventTypeConnected:
log.Info().Msg("connected to slack with socket mode")
// populate usergroups
go getUserGroups(sm, bot)
default:
log.Warn().Msgf("unhandled event type received: %s", evt.Type)
}
}
}()
err := client.Run()
if err != nil {
log.Fatal().Msgf("unable to (re)connect to Slack: %v", err)
}
}
// send - handles the sending logic of a message going to Slack.
func send(api *slack.Client, message models.Message) {
// TODO: potentially long running call depending on workspace size
// only needed for output_to_users functionality which makes some
// unsound and restricting assumptions. refactor!
users, err := getSlackUsers(api, message)
if err != nil {
log.Error().Msgf("problem sending message: %v", err)
}
if message.DirectMessageOnly {
err := handleDirectMessage(api, message)
if err != nil {
log.Error().Msgf("problem sending message: %v", err)
}
} else {
err := handleNonDirectMessage(api, users, message)
if err != nil {
log.Error().Msgf("problem sending message: %v", err)
}
}
}
// sendBackToOriginMessage - sends a message back to where it came from in Slack; this is pretty much a catch-all among the other send functions.
func sendBackToOriginMessage(api *slack.Client, message models.Message) error {
return sendMessage(api, message.IsEphemeral, message.ChannelID, message.Vars["_user.id"], message.Output, message.ThreadTimestamp, message.Remotes.Slack.Attachments)
}
// sendChannelMessage - sends a message to a Slack channel.
func sendChannelMessage(api *slack.Client, channel string, message models.Message) error {
return sendMessage(api, message.IsEphemeral, channel, message.Vars["_user.id"], message.Output, message.ThreadTimestamp, message.Remotes.Slack.Attachments)
}
// sendDirectMessage - sends a message back to the user who dm'ed your bot.
func sendDirectMessage(api *slack.Client, userID string, message models.Message) error {
params := &slack.OpenConversationParameters{
Users: []string{userID},
}
imChannelID, _, _, err := api.OpenConversation(params)
if err != nil {
return err
}
return sendMessage(api, message.IsEphemeral, imChannelID.ID, message.Vars["_user.id"], message.Output, message.ThreadTimestamp, message.Remotes.Slack.Attachments)
}
// sendMessage - does the final send to Slack; adds any Slack-specific message parameters to the message to be sent out.
func sendMessage(api *slack.Client, ephemeral bool, channel, userID, text, threadTimeStamp string, attachments []slack.Attachment) error {
// prepare the message options
opts := []slack.MsgOption{
slack.MsgOptionText(text, false),
slack.MsgOptionAsUser(true),
slack.MsgOptionAttachments(attachments...),
slack.MsgOptionTS(threadTimeStamp),
}
// send as ephemeral
if ephemeral {
_, err := api.PostEphemeral(channel, userID, opts...)
return err
}
// send as regular post
_, _, err := api.PostMessage(channel, opts...)
return err
}
// getUserGroups is a helper function to retrieve all usergroups from the workspace
// and populate the usergroup lookup on the bot object.
// this operation can take a long time on large workspaces and should be
// run in a go routine.
func getUserGroups(client *slack.Client, bot *models.Bot) {
start := time.Now()
// get the user groups
usergroups, err := client.GetUserGroups()
if err != nil {
log.Error().Msgf("error getting user groups: %v", err)
}
// add user groups to the bot
if usergroups != nil {
populateUserGroups(usergroups, bot)
}
log.Info().Msgf("fetched %d usergroups in %s", len(usergroups), time.Since(start).String())
}