diff --git a/examples/interact/main.go b/examples/interact/main.go index 248b8ff19..67308b2a8 100644 --- a/examples/interact/main.go +++ b/examples/interact/main.go @@ -128,7 +128,7 @@ func main() { } ctx := context.Background() - interact.SetMessenger(&interact.Telegram{ + interact.AddMessenger(&interact.Telegram{ Private: true, Bot: b, }) diff --git a/go.mod b/go.mod index 7eace2ffb..84cb1f7f0 100644 --- a/go.mod +++ b/go.mod @@ -43,7 +43,7 @@ require ( github.com/robfig/cron/v3 v3.0.0 github.com/shopspring/decimal v1.2.0 // indirect github.com/sirupsen/logrus v1.8.1 - github.com/slack-go/slack v0.6.6-0.20200602212211-b04b8521281b + github.com/slack-go/slack v0.10.1 github.com/spf13/afero v1.5.1 // indirect github.com/spf13/cast v1.3.1 // indirect github.com/spf13/cobra v1.1.1 diff --git a/go.sum b/go.sum index a3fa25afb..c2a503692 100644 --- a/go.sum +++ b/go.sum @@ -415,6 +415,8 @@ github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/slack-go/slack v0.6.6-0.20200602212211-b04b8521281b h1:4NIpokK7Rg/k6lSzNQzvGLphpHtfAAaLw9AWHxHQn0w= github.com/slack-go/slack v0.6.6-0.20200602212211-b04b8521281b/go.mod h1:FGqNzJBmxIsZURAxh2a8D21AnOVvvXZvGligs4npPUM= +github.com/slack-go/slack v0.10.1 h1:BGbxa0kMsGEvLOEoZmYs8T1wWfoZXwmQFBb6FgYCXUA= +github.com/slack-go/slack v0.10.1/go.mod h1:wWL//kk0ho+FcQXcBTmEafUI5dz4qz5f4mMk8oIkioQ= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s= diff --git a/pkg/bbgo/environment.go b/pkg/bbgo/environment.go index c6290c5f5..d4580e2bc 100644 --- a/pkg/bbgo/environment.go +++ b/pkg/bbgo/environment.go @@ -6,6 +6,7 @@ import ( "fmt" "image/png" "io/ioutil" + stdlog "log" "math/rand" "os" "strings" @@ -16,6 +17,7 @@ import ( "github.com/pkg/errors" "github.com/pquerna/otp" log "github.com/sirupsen/logrus" + "github.com/slack-go/slack" "github.com/spf13/viper" "gopkg.in/tucnak/telebot.v2" @@ -575,7 +577,7 @@ func (environ *Environment) ConfigureNotificationSystem(userConfig *Config) erro // setup slack slackToken := viper.GetString("slack-token") if len(slackToken) > 0 && userConfig.Notifications != nil { - environ.setupSlack(userConfig, slackToken) + environ.setupSlack(userConfig, slackToken, persistence) } // check if telegram bot token is defined @@ -672,18 +674,65 @@ func (environ *Environment) getAuthStore(persistence service.PersistenceService) return persistence.NewStore("bbgo", "auth", id) } -func (environ *Environment) setupSlack(userConfig *Config, slackToken string) { - if conf := userConfig.Notifications.Slack; conf != nil { - if conf.ErrorChannel != "" { - log.Debugf("found slack configured, setting up log hook...") - log.AddHook(slacklog.NewLogHook(slackToken, conf.ErrorChannel)) +func (environ *Environment) setupSlack(userConfig *Config, slackToken string, persistence service.PersistenceService) { + + conf := userConfig.Notifications.Slack + if conf == nil { + return + } + + if !strings.HasPrefix(slackToken, "xoxb-") { + log.Error("SLACK_BOT_TOKEN must have the prefix \"xoxb-\".") + return + } + + // app-level token (for specific api) + slackAppToken := viper.GetString("slack-app-token") + if !strings.HasPrefix(slackAppToken, "xapp-") { + log.Errorf("SLACK_APP_TOKEN must have the prefix \"xapp-\".") + return + } + + if conf.ErrorChannel != "" { + log.Debugf("found slack configured, setting up log hook...") + log.AddHook(slacklog.NewLogHook(slackToken, conf.ErrorChannel)) + } + + log.Debugf("adding slack notifier with default channel: %s", conf.DefaultChannel) + + var client = slack.New(slackToken, + slack.OptionDebug(true), + slack.OptionLog(stdlog.New(os.Stdout, "api: ", stdlog.Lshortfile|stdlog.LstdFlags)), + slack.OptionAppLevelToken(slackAppToken)) + + var notifier = slacknotifier.New(client, conf.DefaultChannel) + environ.AddNotifier(notifier) + + // allocate a store, so that we can save the chatID for the owner + var messenger = interact.NewSlack(client) + + var sessions = interact.SlackSessionMap{} + var sessionStore = persistence.NewStore("bbgo", "slack") + if err := sessionStore.Load(&sessions); err != nil { + log.WithError(err).Errorf("sessions load error") + } else { + for _, session := range sessions { + if session.IsAuthorized() { + // notifier.AddChat(session.Chat) + } } - log.Debugf("adding slack notifier with default channel: %s", conf.DefaultChannel) - - var notifier = slacknotifier.New(slackToken, conf.DefaultChannel) - environ.AddNotifier(notifier) + // you must restore the session after the notifier updates + // messenger.RestoreSessions(sessions) } + + messenger.OnAuthorized(func(userSession *interact.SlackSession) { + if userSession.IsAuthorized() { + // notifier.AddChat(userSession.Chat) + } + + }) + interact.AddMessenger(messenger) } func (environ *Environment) setupTelegram(userConfig *Config, telegramBotToken string, persistence service.PersistenceService) error { @@ -712,10 +761,7 @@ func (environ *Environment) setupTelegram(userConfig *Config, telegramBotToken s environ.Notifiability.AddNotifier(notifier) // allocate a store, so that we can save the chatID for the owner - var messenger = &interact.Telegram{ - Bot: bot, - Private: true, - } + var messenger = interact.NewTelegram(bot) var sessions = interact.TelegramSessionMap{} var sessionStore = persistence.NewStore("bbgo", "telegram", telegramID) @@ -745,7 +791,7 @@ func (environ *Environment) setupTelegram(userConfig *Config, telegramBotToken s } }) - interact.SetMessenger(messenger) + interact.AddMessenger(messenger) return nil } diff --git a/pkg/interact/default.go b/pkg/interact/default.go index 26e08757c..4174122e6 100644 --- a/pkg/interact/default.go +++ b/pkg/interact/default.go @@ -8,8 +8,8 @@ func Default() *Interact { return defaultInteraction } -func SetMessenger(messenger Messenger) { - defaultInteraction.SetMessenger(messenger) +func AddMessenger(messenger Messenger) { + defaultInteraction.AddMessenger(messenger) } func AddCustomInteraction(custom CustomInteraction) { diff --git a/pkg/interact/interact.go b/pkg/interact/interact.go index 67f64740f..3b3b20d86 100644 --- a/pkg/interact/interact.go +++ b/pkg/interact/interact.go @@ -50,7 +50,7 @@ type Interact struct { customInteractions []CustomInteraction - messenger Messenger + messengers []Messenger } func New() *Interact { @@ -172,12 +172,12 @@ func (it *Interact) runCommand(session Session, command string, args []string, c return nil } -func (it *Interact) SetMessenger(messenger Messenger) { +func (it *Interact) AddMessenger(messenger Messenger) { // pass Responder function messenger.SetTextMessageResponder(func(session Session, message string, reply Reply, ctxObjects ...interface{}) error { return it.handleResponse(session, message, append(ctxObjects, reply)...) }) - it.messenger = messenger + it.messengers = append(it.messengers, messenger) } // builtin initializes the built-in commands @@ -222,21 +222,24 @@ func (it *Interact) registerCommands(commands map[string]*Command) error { } // register commands to the service - if it.messenger == nil { + if len(it.messengers) == 0 { return fmt.Errorf("messenger is not set") } + // commandName is used in the closure, we need to copy the variable commandName := n - it.messenger.AddCommand(cmd, func(session Session, message string, reply Reply, ctxObjects ...interface{}) error { - args := parseCommand(message) - return it.runCommand(session, commandName, args, append(ctxObjects, reply)...) - }) + for _, messenger := range it.messengers { + messenger.AddCommand(cmd, func(session Session, message string, reply Reply, ctxObjects ...interface{}) error { + args := parseCommand(message) + return it.runCommand(session, commandName, args, append(ctxObjects, reply)...) + }) + } } return nil } func (it *Interact) Start(ctx context.Context) error { - if it.messenger == nil { + if len(it.messengers) == 0 { log.Warn("messenger is not set, skip initializing") return nil } @@ -256,6 +259,8 @@ func (it *Interact) Start(ctx context.Context) error { } // TODO: use go routine and context - it.messenger.Start(ctx) + for _, m := range it.messengers { + m.Start(ctx) + } return nil } diff --git a/pkg/interact/interact_test.go b/pkg/interact/interact_test.go index ca891f3c2..bd0828240 100644 --- a/pkg/interact/interact_test.go +++ b/pkg/interact/interact_test.go @@ -105,7 +105,7 @@ func TestCustomInteraction(t *testing.T) { telegram := &Telegram{ Bot: b, } - globalInteraction.SetMessenger(telegram) + globalInteraction.AddMessenger(telegram) testInteraction := &TestInteraction{} testInteraction.Commands(globalInteraction) diff --git a/pkg/interact/slack.go b/pkg/interact/slack.go new file mode 100644 index 000000000..863d921b7 --- /dev/null +++ b/pkg/interact/slack.go @@ -0,0 +1,164 @@ +package interact + +import ( + "context" + "fmt" + "log" + "os" + + "github.com/sirupsen/logrus" + "github.com/slack-go/slack" + "github.com/slack-go/slack/slackevents" + "github.com/slack-go/slack/socketmode" +) + +type SlackSession struct { + BaseSession +} + +type SlackSessionMap map[int64]*SlackSession + +//go:generate callbackgen -type Slack +type Slack struct { + client *slack.Client + socket *socketmode.Client + + sessions SlackSessionMap + + commands []*Command + + // textMessageResponder is used for interact to register its message handler + textMessageResponder Responder + + authorizedCallbacks []func(userSession *SlackSession) +} + +func NewSlack(client *slack.Client) *Slack { + socket := socketmode.New( + client, + socketmode.OptionDebug(true), + socketmode.OptionLog( + log.New(os.Stdout, "socketmode: ", + log.Lshortfile|log.LstdFlags)), + ) + + return &Slack{ + client: client, + socket: socket, + } +} + +func (s *Slack) SetTextMessageResponder(responder Responder) { + s.textMessageResponder = responder +} + +func (s *Slack) AddCommand(command *Command, responder Responder) { + s.commands = append(s.commands, command) +} + +func (s *Slack) listen() { + for evt := range s.socket.Events { + 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 { + fmt.Printf("Ignored %+v\n", evt) + + continue + } + + fmt.Printf("Event received: %+v\n", eventsAPIEvent) + + s.socket.Ack(*evt.Request) + + switch eventsAPIEvent.Type { + case slackevents.CallbackEvent: + innerEvent := eventsAPIEvent.InnerEvent + switch ev := innerEvent.Data.(type) { + case *slackevents.AppMentionEvent: + _, _, err := s.client.PostMessage(ev.Channel, slack.MsgOptionText("Yes, hello.", false)) + if err != nil { + fmt.Printf("failed posting message: %v", err) + } + case *slackevents.MemberJoinedChannelEvent: + fmt.Printf("user %q joined to channel %q", ev.User, ev.Channel) + } + default: + s.socket.Debugf("unsupported Events API event received") + } + case socketmode.EventTypeInteractive: + callback, ok := evt.Data.(slack.InteractionCallback) + if !ok { + fmt.Printf("Ignored %+v\n", evt) + + continue + } + + fmt.Printf("Interaction received: %+v\n", callback) + + var payload interface{} + + switch callback.Type { + case slack.InteractionTypeBlockActions: + // See https://api.slack.com/apis/connections/socket-implement#button + + s.socket.Debugf("button clicked!") + case slack.InteractionTypeShortcut: + case slack.InteractionTypeViewSubmission: + // See https://api.slack.com/apis/connections/socket-implement#modal + case slack.InteractionTypeDialogSubmission: + default: + + } + + s.socket.Ack(*evt.Request, payload) + case socketmode.EventTypeSlashCommand: + cmd, ok := evt.Data.(slack.SlashCommand) + if !ok { + fmt.Printf("Ignored %+v\n", evt) + + continue + } + + s.socket.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", + }, + ), + ), + ), + }} + + s.socket.Ack(*evt.Request, payload) + default: + fmt.Fprintf(os.Stderr, "Unexpected event type received: %s\n", evt.Type) + } + } +} + +func (s *Slack) Start(ctx context.Context) { + go s.listen() + 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 4d14ead7a..399b86de5 100644 --- a/pkg/interact/telegram.go +++ b/pkg/interact/telegram.go @@ -96,7 +96,7 @@ type Telegram struct { authorizing bool - sessions map[int64]*TelegramSession + sessions TelegramSessionMap // textMessageResponder is used for interact to register its message handler textMessageResponder Responder @@ -106,6 +106,14 @@ type Telegram struct { authorizedCallbacks []func(s *TelegramSession) } +func NewTelegram(bot *telebot.Bot) *Telegram { + return &Telegram{ + Bot: bot, + Private: true, + sessions: make(map[int64]*TelegramSession), + } +} + func (tm *Telegram) SetTextMessageResponder(textMessageResponder Responder) { tm.textMessageResponder = textMessageResponder } @@ -213,6 +221,10 @@ func (tm *Telegram) RestoreSessions(sessions TelegramSessionMap) { log.Infof("[telegram] restoring telegram %d sessions", len(sessions)) tm.sessions = sessions for _, session := range sessions { + if session.Chat == nil || session.User == nil { + continue + } + if session.IsAuthorized() { if _, err := tm.Bot.Send(session.Chat, fmt.Sprintf("Hi %s, I'm back. Your telegram session is restored.", session.User.Username)); err != nil { log.WithError(err).Error("[telegram] can not send telegram message") diff --git a/pkg/notifier/slacknotifier/slack.go b/pkg/notifier/slacknotifier/slack.go index ec2199c2b..385897d2d 100644 --- a/pkg/notifier/slacknotifier/slack.go +++ b/pkg/notifier/slacknotifier/slack.go @@ -33,10 +33,8 @@ type Notifier struct { type NotifyOption func(notifier *Notifier) -func New(token, channel string, options ...NotifyOption) *Notifier { +func New(client *slack.Client, channel string, options ...NotifyOption) *Notifier { // var client = slack.New(token, slack.OptionDebug(true)) - var client = slack.New(token) - notifier := &Notifier{ channel: channel, client: client,