diff --git a/examples/interact/main.go b/examples/interact/main.go index 67308b2a8..4241be142 100644 --- a/examples/interact/main.go +++ b/examples/interact/main.go @@ -45,7 +45,7 @@ func (m *PositionInteraction) Commands(i *interact.Interact) { // send symbol options reply.Message("Choose your position") for _, symbol := range []string{"BTCUSDT", "ETHUSDT"} { - reply.AddButton(symbol) + reply.AddButton(symbol, symbol, symbol) } return nil @@ -68,7 +68,7 @@ func (m *PositionInteraction) Commands(i *interact.Interact) { reply.Message("Choose or enter the percentage to close") for _, symbol := range []string{"25%", "50%", "100%"} { - reply.AddButton(symbol) + reply.AddButton(symbol, symbol, symbol) } // send percentage options @@ -85,7 +85,7 @@ func (m *PositionInteraction) Commands(i *interact.Interact) { // send confirmation reply.Message("Are you sure to close the position?") - reply.AddButton("Yes") + reply.AddButton("Yes", "confirm", "yes") return nil }).Next(func(reply interact.Reply, confirm string) error { switch strings.ToLower(confirm) { diff --git a/pkg/bbgo/interact.go b/pkg/bbgo/interact.go index d7ef40472..28a7b22cc 100644 --- a/pkg/bbgo/interact.go +++ b/pkg/bbgo/interact.go @@ -44,6 +44,12 @@ func NewCoreInteraction(environment *Environment, trader *Trader) *CoreInteracti func (it *CoreInteraction) Commands(i *interact.Interact) { i.PrivateCommand("/sessions", "List Exchange Sessions", func(reply interact.Reply) error { + switch r := reply.(type) { + case *interact.SlackReply: + // call slack specific api to build the reply object + _ = r + } + message := "Your connected sessions:\n" for name, session := range it.environment.Sessions() { message += "- " + name + " (" + session.ExchangeName.String() + ")\n" @@ -56,7 +62,7 @@ func (it *CoreInteraction) Commands(i *interact.Interact) { i.PrivateCommand("/balances", "Show balances", func(reply interact.Reply) error { reply.Message("Please select an exchange session") for name := range it.environment.Sessions() { - reply.AddButton(name) + reply.AddButton(name, "session", name) } return nil }).Next(func(sessionName string, reply interact.Reply) error { @@ -86,7 +92,7 @@ func (it *CoreInteraction) Commands(i *interact.Interact) { found := false for signature, strategy := range it.exchangeStrategies { if _, ok := strategy.(PositionReader); ok { - reply.AddButton(signature) + reply.AddButton(signature, "strategy", signature) found = true } } @@ -131,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) + reply.AddButton(signature, strategy, signature) found = true } } @@ -173,8 +179,8 @@ func (it *CoreInteraction) Commands(i *interact.Interact) { } reply.Message("Choose or enter the percentage to close") - for _, symbol := range []string{"5%", "25%", "50%", "80%", "100%"} { - reply.AddButton(symbol) + for _, p := range []string{"5%", "25%", "50%", "80%", "100%"} { + reply.AddButton(p, "percentage", p) } return nil diff --git a/pkg/interact/reply.go b/pkg/interact/reply.go index e7fe2b2a9..6866ad85a 100644 --- a/pkg/interact/reply.go +++ b/pkg/interact/reply.go @@ -1,8 +1,33 @@ package interact +type Button struct { + Text string + Name string + Value string +} + type Reply interface { + // Send sends the message directly to the client's session Send(message string) + + // Message sets the message to the reply Message(message string) - AddButton(text string) + + // AddButton adds the button to the reply + AddButton(text string, name, value string) + + // RemoveKeyboard hides the keyboard from the client user interface RemoveKeyboard() } + +// ButtonReply can be used if your reply needs button user interface. +type ButtonReply interface { + // AddButton adds the button to the reply + AddButton(text string) +} + +// DialogReply can be used if your reply needs Dialog user interface +type DialogReply interface { + // AddButton adds the button to the reply + Dialog(title, text string, buttons []string) +} diff --git a/pkg/interact/responder.go b/pkg/interact/responder.go index 26c1b4950..1adfffe30 100644 --- a/pkg/interact/responder.go +++ b/pkg/interact/responder.go @@ -3,6 +3,10 @@ package interact // Responder defines the logic of responding the message type Responder func(session Session, message string, reply Reply, ctxObjects ...interface{}) error +type CallbackResponder interface { + SetCallbackResponder(responder Responder) +} + type TextMessageResponder interface { SetTextMessageResponder(responder Responder) } diff --git a/pkg/interact/slack.go b/pkg/interact/slack.go index 863d921b7..a360e5201 100644 --- a/pkg/interact/slack.go +++ b/pkg/interact/slack.go @@ -6,14 +6,108 @@ import ( "log" "os" + "github.com/google/uuid" "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 + uuid string + + session *SlackSession + + client *slack.Client + + message string + + accessories []*slack.Accessory +} + +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 { + logrus.WithError(err).Errorf("slack post message error: channel=%s thread=%s", cID, tsID) + return + } +} + +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) { + 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, + }, + ), + )) +} + +func (reply *SlackReply) build() map[string]interface{} { + var blocks []slack.Block + + blocks = append(blocks, slack.NewSectionBlock( + &slack.TextBlockObject{ + Type: slack.MarkdownType, + Text: reply.message, + }, + nil, // fields + nil, // accessory + nil, // options + )) + + blocks = append(blocks, slack.NewActionBlock("", + slack.NewButtonBlockElement( + "actionID", + "value", + slack.NewTextBlockObject( + slack.PlainTextType, "text", true, false), + ))) + + var payload = map[string]interface{}{ + "blocks": blocks, + } + return payload +} + type SlackSession struct { BaseSession + + ChannelID string + UserID string + + // questions is used to store the questions that we added in the reply + // the key is the client generated callback id + questions map[string]interface{} +} + +func NewSlackSession() *SlackSession { + return &SlackSession{ + questions: make(map[string]interface{}), + } +} + +func (s *SlackSession) ID() string { + return fmt.Sprintf("%s-%s", s.UserID, s.ChannelID) } type SlackSessionMap map[int64]*SlackSession @@ -31,6 +125,8 @@ type Slack struct { textMessageResponder Responder authorizedCallbacks []func(userSession *SlackSession) + + eventsApiCallbacks []func(slackevents.EventsAPIEvent) } func NewSlack(client *slack.Client) *Slack { @@ -68,15 +164,15 @@ func (s *Slack) listen() { case socketmode.EventTypeEventsAPI: eventsAPIEvent, ok := evt.Data.(slackevents.EventsAPIEvent) if !ok { - fmt.Printf("Ignored %+v\n", evt) - + logrus.Debugf("ignored %+v", evt) continue } - fmt.Printf("Event received: %+v\n", eventsAPIEvent) - + logrus.Debugf("event received: %+v", eventsAPIEvent) s.socket.Ack(*evt.Request) + s.EmitEventsApi(eventsAPIEvent) + switch eventsAPIEvent.Type { case slackevents.CallbackEvent: innerEvent := eventsAPIEvent.InnerEvent @@ -95,20 +191,19 @@ func (s *Slack) listen() { case socketmode.EventTypeInteractive: callback, ok := evt.Data.(slack.InteractionCallback) if !ok { - fmt.Printf("Ignored %+v\n", evt) - + logrus.Debugf("ignored %+v", evt) continue } - fmt.Printf("Interaction received: %+v\n", callback) + logrus.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!") - s.socket.Debugf("button clicked!") case slack.InteractionTypeShortcut: case slack.InteractionTypeViewSubmission: // See https://api.slack.com/apis/connections/socket-implement#modal @@ -118,47 +213,44 @@ func (s *Slack) listen() { } s.socket.Ack(*evt.Request, payload) + case socketmode.EventTypeSlashCommand: cmd, ok := evt.Data.(slack.SlashCommand) if !ok { - fmt.Printf("Ignored %+v\n", evt) - + logrus.Debugf("ignored %+v", evt) continue } - s.socket.Debugf("Slash command received: %+v", cmd) + logrus.Debugf("slash command received: %+v", cmd) - payload := map[string]interface{}{ - "blocks": []slack.Block{ - slack.NewSectionBlock( - &slack.TextBlockObject{ - Type: slack.MarkdownType, - Text: "foo", - }, - nil, - slack.NewAccessory( - slack.NewButtonBlockElement( - "", - "somevalue", - &slack.TextBlockObject{ - Type: slack.PlainTextType, - Text: "bar", - }, - ), - ), - ), - }} + session := s.newSession(evt) + reply := s.newReply(session) + if err := s.textMessageResponder(session, "", reply); err != nil { + continue + } + payload := reply.build() s.socket.Ack(*evt.Request, payload) default: - fmt.Fprintf(os.Stderr, "Unexpected event type received: %s\n", evt.Type) + logrus.Debugf("unexpected event type received: %s", evt.Type) } } } +func (s *Slack) newSession(evt socketmode.Event) *SlackSession { + return NewSlackSession() +} + +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() - if err := s.socket.Run() ; err != nil { + if err := s.socket.Run(); err != nil { logrus.WithError(err).Errorf("slack socketmode error") } } diff --git a/pkg/interact/telegram.go b/pkg/interact/telegram.go index 27f01b2a1..b7ee44204 100644 --- a/pkg/interact/telegram.go +++ b/pkg/interact/telegram.go @@ -70,7 +70,7 @@ func (r *TelegramReply) RemoveKeyboard() { r.set = true } -func (r *TelegramReply) AddButton(text string) { +func (r *TelegramReply) AddButton(text string, name string, value string) { var button = r.menu.Text(text) if len(r.buttons) == 0 { r.buttons = append(r.buttons, []telebot.Btn{}) @@ -101,6 +101,8 @@ type Telegram struct { // textMessageResponder is used for interact to register its message handler textMessageResponder Responder + callbackResponder CallbackResponder + commands []*Command authorizedCallbacks []func(s *TelegramSession) @@ -114,8 +116,12 @@ func NewTelegram(bot *telebot.Bot) *Telegram { } } -func (tm *Telegram) SetTextMessageResponder(textMessageResponder Responder) { - tm.textMessageResponder = textMessageResponder +func (tm *Telegram) SetCallbackResponder(responder CallbackResponder) { + tm.callbackResponder = responder +} + +func (tm *Telegram) SetTextMessageResponder(responder Responder) { + tm.textMessageResponder = responder } func (tm *Telegram) Start(context.Context) {