From 0af5fc0530eaf1d791e1e027c9d1f42505b69f15 Mon Sep 17 00:00:00 2001 From: c9s Date: Fri, 21 Jan 2022 00:01:22 +0800 Subject: [PATCH] interact: add RequireTextInput method to Reply interface --- pkg/bbgo/interact.go | 2 +- pkg/interact/reply.go | 2 + pkg/interact/slack.go | 196 +++++++++++++++++++++++--------- pkg/interact/slack_callbacks.go | 6 +- pkg/interact/telegram.go | 10 ++ 5 files changed, 159 insertions(+), 57 deletions(-) diff --git a/pkg/bbgo/interact.go b/pkg/bbgo/interact.go index 28a7b22cc..c9022433f 100644 --- a/pkg/bbgo/interact.go +++ b/pkg/bbgo/interact.go @@ -137,7 +137,7 @@ func (it *CoreInteraction) Commands(i *interact.Interact) { found := false for signature, strategy := range it.exchangeStrategies { if _, ok := strategy.(PositionCloser); ok { - reply.AddButton(signature, strategy, signature) + reply.AddButton(signature, signature, signature) found = true } } diff --git a/pkg/interact/reply.go b/pkg/interact/reply.go index 6866ad85a..23cf36323 100644 --- a/pkg/interact/reply.go +++ b/pkg/interact/reply.go @@ -16,6 +16,8 @@ type Reply interface { // AddButton adds the button to the reply AddButton(text string, name, value string) + RequireTextInput(title, message string, textFields ...TextField) + // RemoveKeyboard hides the keyboard from the client user interface RemoveKeyboard() } diff --git a/pkg/interact/slack.go b/pkg/interact/slack.go index a360e5201..6555fe6de 100644 --- a/pkg/interact/slack.go +++ b/pkg/interact/slack.go @@ -3,17 +3,16 @@ package interact import ( "context" "fmt" - "log" + stdlog "log" "os" "github.com/google/uuid" - "github.com/sirupsen/logrus" + log "github.com/sirupsen/logrus" "github.com/slack-go/slack" "github.com/slack-go/slack/slackevents" "github.com/slack-go/slack/socketmode" ) - type SlackReply struct { // uuid is the unique id of this question // can be used as the callback id @@ -25,7 +24,7 @@ type SlackReply struct { message string - accessories []*slack.Accessory + buttons []Button } func (reply *SlackReply) Send(message string) { @@ -35,7 +34,7 @@ func (reply *SlackReply) Send(message string) { 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 { - logrus.WithError(err).Errorf("slack post message error: channel=%s thread=%s", cID, tsID) + log.WithError(err).Errorf("slack post message error: channel=%s thread=%s", cID, tsID) return } } @@ -48,18 +47,11 @@ func (reply *SlackReply) Message(message string) { func (reply *SlackReply) RemoveKeyboard() {} func (reply *SlackReply) AddButton(text string, name string, value string) { - actionID := reply.uuid + ":" + value - reply.accessories = append(reply.accessories, slack.NewAccessory( - slack.NewButtonBlockElement( - // action id should be unique - actionID, - value, - &slack.TextBlockObject{ - Type: slack.PlainTextType, - Text: text, - }, - ), - )) + reply.buttons = append(reply.buttons, Button{ + Text: text, + Name: name, + Value: value, + }) } func (reply *SlackReply) build() map[string]interface{} { @@ -72,16 +64,27 @@ func (reply *SlackReply) build() map[string]interface{} { }, nil, // fields nil, // accessory - nil, // options + slack.SectionBlockOptionBlockID(reply.uuid), )) - blocks = append(blocks, slack.NewActionBlock("", - slack.NewButtonBlockElement( - "actionID", - "value", - slack.NewTextBlockObject( - slack.PlainTextType, "text", true, false), - ))) + 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 = append(blocks, slack.NewActionBlock(reply.uuid, buttons...)) + } var payload = map[string]interface{}{ "blocks": blocks, @@ -100,17 +103,19 @@ type SlackSession struct { questions map[string]interface{} } -func NewSlackSession() *SlackSession { +func NewSlackSession(userID string) *SlackSession { return &SlackSession{ + UserID: userID, questions: make(map[string]interface{}), } } func (s *SlackSession) ID() string { - return fmt.Sprintf("%s-%s", s.UserID, s.ChannelID) + return s.UserID + // return fmt.Sprintf("%s-%s", s.UserID, s.ChannelID) } -type SlackSessionMap map[int64]*SlackSession +type SlackSessionMap map[string]*SlackSession //go:generate callbackgen -type Slack type Slack struct { @@ -119,14 +124,15 @@ type Slack struct { sessions SlackSessionMap - commands []*Command + 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(slackevents.EventsAPIEvent) + eventsApiCallbacks []func(evt slackevents.EventsAPIEvent) } func NewSlack(client *slack.Client) *Slack { @@ -134,13 +140,16 @@ func NewSlack(client *slack.Client) *Slack { client, socketmode.OptionDebug(true), socketmode.OptionLog( - log.New(os.Stdout, "socketmode: ", - log.Lshortfile|log.LstdFlags)), + stdlog.New(os.Stdout, "socketmode: ", + stdlog.Lshortfile|stdlog.LstdFlags)), ) return &Slack{ - client: client, - socket: socket, + client: client, + socket: socket, + sessions: make(SlackSessionMap), + commands: make(map[string]*Command), + commandResponders: make(map[string]Responder), } } @@ -149,26 +158,36 @@ func (s *Slack) SetTextMessageResponder(responder Responder) { } func (s *Slack) AddCommand(command *Command, responder Responder) { - s.commands = append(s.commands, command) + 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() { for evt := range s.socket.Events { + log.Debugf("event: %+v", evt) + switch evt.Type { case socketmode.EventTypeConnecting: fmt.Println("Connecting to Slack with Socket Mode...") + case socketmode.EventTypeConnectionError: fmt.Println("Connection failed. Retrying later...") + case socketmode.EventTypeConnected: fmt.Println("Connected to Slack with Socket Mode.") + case socketmode.EventTypeEventsAPI: eventsAPIEvent, ok := evt.Data.(slackevents.EventsAPIEvent) if !ok { - logrus.Debugf("ignored %+v", evt) + log.Debugf("ignored %+v", evt) continue } - logrus.Debugf("event received: %+v", eventsAPIEvent) + log.Debugf("event received: %+v", eventsAPIEvent) s.socket.Ack(*evt.Request) s.EmitEventsApi(eventsAPIEvent) @@ -191,18 +210,21 @@ func (s *Slack) listen() { case socketmode.EventTypeInteractive: callback, ok := evt.Data.(slack.InteractionCallback) if !ok { - logrus.Debugf("ignored %+v", evt) + log.Debugf("ignored %+v", evt) continue } - logrus.Debugf("interaction received: %+v", callback) + 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 - logrus.Debugf("button clicked!") + log.Debugf("button clicked!") + // TODO: check and find what's the response handler for the reply + // we need to find the session first, + // and then look up the state, call the function to transit the state with the given value case slack.InteractionTypeShortcut: case slack.InteractionTypeViewSubmission: @@ -214,31 +236,55 @@ func (s *Slack) listen() { s.socket.Ack(*evt.Request, payload) + case socketmode.EventTypeHello: + log.Debugf("hello command received: %+v", evt) + case socketmode.EventTypeSlashCommand: - cmd, ok := evt.Data.(slack.SlashCommand) + slashCmd, ok := evt.Data.(slack.SlashCommand) if !ok { - logrus.Debugf("ignored %+v", evt) + log.Debugf("ignored %+v", evt) continue } - logrus.Debugf("slash command received: %+v", cmd) + log.Debugf("slash command received: %+v", slashCmd) + if responder, exists := s.commandResponders[slashCmd.Command]; exists { + session := s.findSession(evt, slashCmd.UserID) + reply := s.newReply(session) + if err := responder(session, slashCmd.Text, reply); err != nil { + log.WithError(err).Errorf("responder returns error") + continue + } - session := s.newSession(evt) - reply := s.newReply(session) - if err := s.textMessageResponder(session, "", reply); err != nil { - continue + req := generateTextInputModalRequest("Authentication", "Please enter your code", TextField{ + Label: "First Name", + Name: "first_name", + PlaceHolder: "Enter your first name", + }) + // s.socket.Ack(*evt.Request, req) + if resp, err := s.client.OpenView(slashCmd.TriggerID, req); err != nil { + log.WithError(err).Error("view open error, resp: %+v", resp) + } + + payload := reply.build() + s.socket.Ack(*evt.Request, payload) + } else { + log.Errorf("command %s does not exist", slashCmd.Command) } - payload := reply.build() - s.socket.Ack(*evt.Request, payload) default: - logrus.Debugf("unexpected event type received: %s", evt.Type) + log.Debugf("unexpected event type received: %s", evt.Type) } } } -func (s *Slack) newSession(evt socketmode.Event) *SlackSession { - return NewSlackSession() +func (s *Slack) findSession(evt socketmode.Event, userID string) *SlackSession { + if session, ok := s.sessions[userID]; ok { + return session + } + + session := NewSlackSession(userID) + s.sessions[userID] = session + return session } func (s *Slack) newReply(session *SlackSession) *SlackReply { @@ -251,6 +297,50 @@ func (s *Slack) newReply(session *SlackSession) *SlackReply { func (s *Slack) Start(ctx context.Context) { go s.listen() if err := s.socket.Run(); err != nil { - logrus.WithError(err).Errorf("slack socketmode error") + log.WithError(err).Errorf("slack socketmode error") } } + +type TextField struct { + // Label is the field label + Label string + + // Name is the form field name + Name string + + // PlaceHolder is the sample text in the text input + PlaceHolder string +} + +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 { + firstNameText := slack.NewTextBlockObject("plain_text", textField.Label, false, false) + firstNamePlaceholder := slack.NewTextBlockObject("plain_text", textField.PlaceHolder, false, false) + firstNameElement := slack.NewPlainTextInputBlockElement(firstNamePlaceholder, textField.Name) + // Notice that blockID is a unique identifier for a block + firstName := slack.NewInputBlock(textField.Name, firstNameText, firstNameElement) + blocks.BlockSet = append(blocks.BlockSet, firstName) + } + + var modalRequest slack.ModalViewRequest + modalRequest.Type = slack.ViewType("modal") + modalRequest.Title = titleText + modalRequest.Close = closeText + modalRequest.Submit = submitText + modalRequest.Blocks = blocks + return modalRequest +} diff --git a/pkg/interact/slack_callbacks.go b/pkg/interact/slack_callbacks.go index eaa9bd987..40460f331 100644 --- a/pkg/interact/slack_callbacks.go +++ b/pkg/interact/slack_callbacks.go @@ -16,12 +16,12 @@ func (s *Slack) EmitAuthorized(userSession *SlackSession) { } } -func (s *Slack) OnEventsApi(cb func(slackevents.EventsAPIEvent)) { +func (s *Slack) OnEventsApi(cb func(evt slackevents.EventsAPIEvent)) { s.eventsApiCallbacks = append(s.eventsApiCallbacks, cb) } -func (s *Slack) EmitEventsApi(slackevents.EventsAPIEvent) { +func (s *Slack) EmitEventsApi(evt slackevents.EventsAPIEvent) { for _, cb := range s.eventsApiCallbacks { - cb() + cb(evt) } } diff --git a/pkg/interact/telegram.go b/pkg/interact/telegram.go index b7ee44204..37eb26dee 100644 --- a/pkg/interact/telegram.go +++ b/pkg/interact/telegram.go @@ -10,6 +10,11 @@ import ( "gopkg.in/tucnak/telebot.v2" ) +func init() { + // force interface type check + _ = Reply(&TelegramReply{}) +} + type TelegramSessionMap map[int64]*TelegramSession type TelegramSession struct { @@ -56,6 +61,7 @@ type TelegramReply struct { set bool } + func (r *TelegramReply) Send(message string) { checkSendErr(r.bot.Send(r.session.Chat, message)) } @@ -65,6 +71,10 @@ func (r *TelegramReply) Message(message string) { r.set = true } +func (r *TelegramReply) RequireTextInput(title, message string, textFields ...TextField) { + r.message = message +} + func (r *TelegramReply) RemoveKeyboard() { r.menu.ReplyKeyboardRemove = true r.set = true