package interact import ( "context" "encoding/json" "fmt" stdlog "log" "os" "time" "github.com/google/uuid" log "github.com/sirupsen/logrus" "github.com/slack-go/slack" "github.com/slack-go/slack/slackevents" "github.com/slack-go/slack/socketmode" "git.qtrade.icu/lychiyu/qbtrade/pkg/util" ) type SlackReply struct { // uuid is the unique id of this question // can be used as the callback id uuid string session *SlackSession client *slack.Client message string buttons []Button textInputModalViewRequest *slack.ModalViewRequest } func (reply *SlackReply) Send(message string) { cID, tsID, err := reply.client.PostMessage( reply.session.ChannelID, slack.MsgOptionText(message, false), slack.MsgOptionAsUser(false), // Add this if you want that the bot would post message as a user, otherwise it will send response using the default slackbot ) if err != nil { log.WithError(err).Errorf("slack post message error: channel=%s thread=%s", cID, tsID) return } } func (reply *SlackReply) InputText(prompt string, textFields ...TextField) { reply.message = prompt reply.textInputModalViewRequest = generateTextInputModalRequest(prompt, prompt, textFields...) } func (reply *SlackReply) Choose(prompt string, options ...Option) { } func (reply *SlackReply) Message(message string) { reply.message = message } // RemoveKeyboard is not supported by Slack func (reply *SlackReply) RemoveKeyboard() {} func (reply *SlackReply) AddButton(text string, name string, value string) { reply.buttons = append(reply.buttons, Button{ Text: text, Name: name, Value: value, }) } func (reply *SlackReply) AddMultipleButtons(buttonsForm [][3]string) { for _, buttonForm := range buttonsForm { reply.AddButton(buttonForm[0], buttonForm[1], buttonForm[2]) } } func (reply *SlackReply) build() interface{} { // you should avoid using this modal view request, because it interrupts the interaction flow // once we send the modal view request, we can't go back to the channel. // (we don't know which channel the user started the interaction) if reply.textInputModalViewRequest != nil { return reply.textInputModalViewRequest } if len(reply.message) > 0 { return reply.message } var blocks slack.Blocks blocks.BlockSet = append(blocks.BlockSet, slack.NewSectionBlock( &slack.TextBlockObject{ Type: slack.MarkdownType, Text: reply.message, }, nil, // fields nil, // accessory slack.SectionBlockOptionBlockID(reply.uuid), )) if len(reply.buttons) > 0 { var buttons []slack.BlockElement for _, btn := range reply.buttons { actionID := reply.uuid + ":" + btn.Value buttons = append(buttons, slack.NewButtonBlockElement( // action id should be unique actionID, btn.Value, &slack.TextBlockObject{ Type: slack.PlainTextType, Text: btn.Text, }, ), ) } blocks.BlockSet = append(blocks.BlockSet, slack.NewActionBlock(reply.uuid, buttons...)) } return blocks } type SlackSession struct { BaseSession slack *Slack ChannelID string UserID string } func NewSlackSession(slack *Slack, userID, channelID string) *SlackSession { return &SlackSession{ BaseSession: BaseSession{ OriginState: StatePublic, CurrentState: StatePublic, Authorized: false, authorizing: false, StartedTime: time.Now(), }, slack: slack, UserID: userID, ChannelID: channelID, } } func (s *SlackSession) ID() string { return fmt.Sprintf("%s-%s", s.UserID, s.ChannelID) } func (s *SlackSession) SetAuthorized() { s.BaseSession.SetAuthorized() s.slack.EmitAuthorized(s) } type SlackSessionMap map[string]*SlackSession //go:generate callbackgen -type Slack type Slack struct { client *slack.Client socket *socketmode.Client sessions SlackSessionMap commands map[string]*Command commandResponders map[string]Responder // textMessageResponder is used for interact to register its message handler textMessageResponder Responder authorizedCallbacks []func(userSession *SlackSession) eventsApiCallbacks []func(evt slackevents.EventsAPIEvent) } func NewSlack(client *slack.Client) *Slack { var opts = []socketmode.Option{ socketmode.OptionLog( stdlog.New(os.Stdout, "socketmode: ", stdlog.Lshortfile|stdlog.LstdFlags)), } if b, ok := util.GetEnvVarBool("DEBUG_SLACK"); ok { opts = append(opts, socketmode.OptionDebug(b)) } socket := socketmode.New(client, opts...) return &Slack{ client: client, socket: socket, sessions: make(SlackSessionMap), commands: make(map[string]*Command), commandResponders: make(map[string]Responder), } } func (s *Slack) SetTextMessageResponder(responder Responder) { s.textMessageResponder = responder } func (s *Slack) AddCommand(command *Command, responder Responder) { if _, exists := s.commands[command.Name]; exists { panic(fmt.Errorf("command %s already exists, can not be re-defined", command.Name)) } s.commands[command.Name] = command s.commandResponders[command.Name] = responder } func (s *Slack) listen(ctx context.Context) { for evt := range s.socket.Events { log.Debugf("event: %+v", evt) switch evt.Type { case socketmode.EventTypeConnecting: log.Infof("connecting to slack with socket mode...") case socketmode.EventTypeConnectionError: log.Infof("connection failed. retrying later...") case socketmode.EventTypeConnected: log.Infof("connected to slack with socket mode.") case socketmode.EventTypeDisconnect: log.Infof("slack socket mode disconnected") case socketmode.EventTypeEventsAPI: eventsAPIEvent, ok := evt.Data.(slackevents.EventsAPIEvent) if !ok { log.Debugf("ignored %+v", evt) continue } log.Debugf("event received: %+v", eventsAPIEvent) // events api don't have response trigger, we can't set the response s.socket.Ack(*evt.Request) s.EmitEventsApi(eventsAPIEvent) switch eventsAPIEvent.Type { case slackevents.CallbackEvent: innerEvent := eventsAPIEvent.InnerEvent switch ev := innerEvent.Data.(type) { case *slackevents.MessageEvent: log.Debugf("message event: text=%+v", ev.Text) if len(ev.BotID) > 0 { log.Debug("skip bot message") continue } session := s.loadSession(evt, ev.User, ev.Channel) if !session.authorizing && !session.Authorized { log.Warn("[slack] session is not authorizing nor authorized, skipping message handler") continue } if s.textMessageResponder != nil { reply := s.newReply(session) if err := s.textMessageResponder(session, ev.Text, reply); err != nil { log.WithError(err).Errorf("[slack] response handling error") continue } // build the response response := reply.build() log.Debugln("response payload", toJson(response)) switch response := response.(type) { case string: _, _, err := s.client.PostMessage(ev.Channel, slack.MsgOptionText(response, false)) if err != nil { log.WithError(err).Error("failed posting plain text message") } case slack.Blocks: _, _, err := s.client.PostMessage(ev.Channel, slack.MsgOptionBlocks(response.BlockSet...)) if err != nil { log.WithError(err).Error("failed posting blocks message") } default: log.Errorf("[slack] unexpected message type %T: %+v", response, response) } } case *slackevents.AppMentionEvent: log.Infof("app mention event: %+v", ev) s.socket.Ack(*evt.Request) case *slackevents.MemberJoinedChannelEvent: log.Infof("user %q joined to channel %q", ev.User, ev.Channel) s.socket.Ack(*evt.Request) } default: s.socket.Debugf("unsupported Events API event received") } case socketmode.EventTypeInteractive: callback, ok := evt.Data.(slack.InteractionCallback) if !ok { log.Debugf("ignored %+v", evt) continue } log.Debugf("interaction received: %+v", callback) var payload interface{} switch callback.Type { case slack.InteractionTypeBlockActions: // See https://api.slack.com/apis/connections/socket-implement#button log.Debugf("InteractionTypeBlockActions: %+v", callback) case slack.InteractionTypeShortcut: log.Debugf("InteractionTypeShortcut: %+v", callback) case slack.InteractionTypeViewSubmission: // See https://api.slack.com/apis/connections/socket-implement#modal log.Debugf("[slack] InteractionTypeViewSubmission: %+v", callback) var values = simplifyStateValues(callback.View.State) if len(values) > 1 { log.Warnf("[slack] more than 1 values received from the modal view submission, the value choosen from the state values might be incorrect") } log.Debugln(toJson(values)) if inputValue, ok := takeOneValue(values); ok { session := s.loadSession(evt, callback.User.ID, callback.Channel.ID) if !session.authorizing && !session.Authorized { log.Warn("[slack] telegram is set to private mode, skipping message") continue } reply := s.newReply(session) if s.textMessageResponder != nil { if err := s.textMessageResponder(session, inputValue, reply); err != nil { log.WithError(err).Errorf("[slack] response handling error") continue } } // close the modal view by sending a null payload s.socket.Ack(*evt.Request) // build the response response := reply.build() log.Debugln("response payload", toJson(response)) switch response := response.(type) { case string: payload = map[string]interface{}{ "blocks": []slack.Block{ translateMessageToBlock(response), }, } case slack.Blocks: payload = map[string]interface{}{ "blocks": response.BlockSet, } default: s.socket.Ack(*evt.Request, response) } } case slack.InteractionTypeDialogSubmission: log.Debugf("[slack] InteractionTypeDialogSubmission: %+v", callback) default: log.Debugf("[slack] unexpected callback type: %+v", callback) } s.socket.Ack(*evt.Request, payload) case socketmode.EventTypeHello: log.Debugf("[slack] hello command received: %+v", evt) case socketmode.EventTypeSlashCommand: slashCmd, ok := evt.Data.(slack.SlashCommand) if !ok { log.Debugf("[slack] ignored %+v", evt) continue } log.Debugf("[slack] slash command received: %+v", slashCmd) responder, exists := s.commandResponders[slashCmd.Command] if !exists { log.Errorf("[slack] command %s does not exist", slashCmd.Command) s.socket.Ack(*evt.Request) continue } session := s.loadSession(evt, slashCmd.UserID, slashCmd.ChannelID) reply := s.newReply(session) if err := responder(session, slashCmd.Text, reply); err != nil { log.WithError(err).Errorf("[slack] responder returns error") s.socket.Ack(*evt.Request) continue } payload := reply.build() if payload == nil { log.Warnf("[slack] reply returns nil payload") // ack with empty payload s.socket.Ack(*evt.Request) continue } switch o := payload.(type) { case string: s.socket.Ack(*evt.Request, map[string]interface{}{ "blocks": []slack.Block{ translateMessageToBlock(o), }, }) case *slack.ModalViewRequest: if resp, err := s.socket.OpenView(slashCmd.TriggerID, *o); err != nil { log.WithError(err).Errorf("[slack] view open error, resp: %+v", resp) } s.socket.Ack(*evt.Request) case slack.Blocks: s.socket.Ack(*evt.Request, map[string]interface{}{ "blocks": o.BlockSet, }) default: s.socket.Ack(*evt.Request, o) } default: log.Debugf("[slack] unexpected event type received: %s", evt.Type) } } } func (s *Slack) loadSession(evt socketmode.Event, userID, channelID string) *SlackSession { key := userID + "-" + channelID if session, ok := s.sessions[key]; ok { log.Infof("[slack] an existing session %q found, session: %+v", key, session) return session } session := NewSlackSession(s, userID, channelID) s.sessions[key] = session log.Infof("[slack] allocated a new session %q, session: %+v", key, session) return session } func (s *Slack) newReply(session *SlackSession) *SlackReply { return &SlackReply{ uuid: uuid.New().String(), session: session, } } func (s *Slack) Start(ctx context.Context) { go s.listen(ctx) if err := s.socket.Run(); err != nil { log.WithError(err).Errorf("slack socketmode error") } } // generateTextInputModalRequest generates a general slack modal view request with the given text fields // see also https://api.slack.com/surfaces/modals/using#opening func generateTextInputModalRequest(title string, prompt string, textFields ...TextField) *slack.ModalViewRequest { // create a ModalViewRequest with a header and two inputs titleText := slack.NewTextBlockObject("plain_text", title, false, false) closeText := slack.NewTextBlockObject("plain_text", "Close", false, false) submitText := slack.NewTextBlockObject("plain_text", "Submit", false, false) headerText := slack.NewTextBlockObject("mrkdwn", prompt, false, false) headerSection := slack.NewSectionBlock(headerText, nil, nil) blocks := slack.Blocks{ BlockSet: []slack.Block{ headerSection, }, } for _, textField := range textFields { labelObject := slack.NewTextBlockObject("plain_text", textField.Label, false, false) placeHolderObject := slack.NewTextBlockObject("plain_text", textField.PlaceHolder, false, false) textInputObject := slack.NewPlainTextInputBlockElement(placeHolderObject, textField.Name) // Notice that blockID is a unique identifier for a block inputBlock := slack.NewInputBlock("block-"+textField.Name+"-"+uuid.NewString(), labelObject, placeHolderObject, textInputObject) blocks.BlockSet = append(blocks.BlockSet, inputBlock) } var modalRequest slack.ModalViewRequest modalRequest.Type = slack.ViewType("modal") modalRequest.Title = titleText modalRequest.Close = closeText modalRequest.Submit = submitText modalRequest.Blocks = blocks return &modalRequest } // simplifyStateValues simplifies the multi-layer structured values into just name=value mapping func simplifyStateValues(state *slack.ViewState) map[string]string { var values = make(map[string]string) if state == nil { return values } for blockID, fields := range state.Values { _ = blockID for fieldName, fieldValues := range fields { values[fieldName] = fieldValues.Value } } return values } func takeOneValue(values map[string]string) (string, bool) { for _, v := range values { return v, true } return "", false } func toJson(v interface{}) string { o, err := json.MarshalIndent(v, "", " ") if err != nil { log.WithError(err).Errorf("json marshal error") return "" } return string(o) } func translateMessageToBlock(message string) slack.Block { return slack.NewSectionBlock( &slack.TextBlockObject{ Type: slack.MarkdownType, Text: message, }, nil, // fields nil, // accessory // slack.SectionBlockOptionBlockID(reply.uuid), ) }