diff --git a/examples/interact/main.go b/examples/interact/main.go new file mode 100644 index 000000000..a20dc4ca5 --- /dev/null +++ b/examples/interact/main.go @@ -0,0 +1,140 @@ +package main + +import ( + "context" + "fmt" + "os" + "strconv" + "strings" + "syscall" + "time" + + log "github.com/sirupsen/logrus" + tb "gopkg.in/tucnak/telebot.v2" + + "github.com/c9s/bbgo/pkg/cmd/cmdutil" + "github.com/c9s/bbgo/pkg/interact" +) + +func parseFloatPercent(s string, bitSize int) (f float64, err error) { + i := strings.Index(s, "%") + if i < 0 { + return strconv.ParseFloat(s, bitSize) + } + + f, err = strconv.ParseFloat(s[:i], bitSize) + if err != nil { + return 0, err + } + return f / 100.0, nil +} + +type closePositionTask struct { + symbol string + percentage float64 + confirmed bool +} + +type PositionInteraction struct { + closePositionTask closePositionTask +} + +func (m *PositionInteraction) Commands(i *interact.Interact) { + i.Command("/closePosition", func(reply interact.Reply) error { + // send symbol options + reply.Message("Choose your position") + for _, symbol := range []string{"BTCUSDT", "ETHUSDT"} { + reply.AddButton(symbol) + } + + return nil + }).Next(func(reply interact.Reply, symbol string) error { + // get symbol from user + if len(symbol) == 0 { + reply.Message("Please enter a symbol") + return fmt.Errorf("empty symbol") + } + switch symbol { + case "BTCUSDT", "ETHUSDT": + + default: + reply.Message("Invalid symbol") + return fmt.Errorf("invalid symbol") + + } + + m.closePositionTask.symbol = symbol + + reply.Message("Choose or enter the percentage to close") + for _, symbol := range []string{"25%", "50%", "100%"} { + reply.AddButton(symbol) + } + + // send percentage options + return nil + }).Next(func(reply interact.Reply, percentageStr string) error { + p, err := parseFloatPercent(percentageStr, 64) + if err != nil { + reply.Message("Not a valid percentage string") + return err + } + + // get percentage from user + m.closePositionTask.percentage = p + + // send confirmation + reply.Message("Are you sure to close the position?") + reply.AddButton("Yes") + return nil + }).Next(func(reply interact.Reply, confirm string) error { + switch strings.ToLower(confirm) { + case "yes": + m.closePositionTask.confirmed = true + reply.Message(fmt.Sprintf("Your %s position is closed", m.closePositionTask.symbol)) + reply.RemoveKeyboard() + + default: + + } + // call position close + + // reply result + return nil + }) +} + +func main() { + b, err := tb.NewBot(tb.Settings{ + // You can also set custom API URL. + // If field is empty it equals to "https://api.telegram.org". + // URL: "http://195.129.111.17:8012", + + Token: os.Getenv("TELEGRAM_BOT_TOKEN"), + Poller: &tb.LongPoller{Timeout: 10 * time.Second}, + // Synchronous: false, + // Verbose: true, + // ParseMode: "", + // Reporter: nil, + // Client: nil, + // Offline: false, + }) + + if err != nil { + log.Fatal(err) + return + } + + ctx := context.Background() + + globalInteraction := interact.New() + globalInteraction.SetMessenger(&interact.Telegram{ + Interact: globalInteraction, + Bot: b, + }) + + globalInteraction.AddCustomInteraction(&PositionInteraction{}) + if err := globalInteraction.Start(ctx); err != nil { + log.Fatal(err) + } + cmdutil.WaitForSignal(ctx, syscall.SIGINT, syscall.SIGTERM) +} diff --git a/examples/telebot/main.go b/examples/telebot/main.go index 1c5e54b2f..64fa760ba 100644 --- a/examples/telebot/main.go +++ b/examples/telebot/main.go @@ -1,12 +1,11 @@ package main import ( - "fmt" - "log" "os" "time" "github.com/google/uuid" + log "github.com/sirupsen/logrus" tb "gopkg.in/tucnak/telebot.v2" ) @@ -33,7 +32,7 @@ func main() { Token: os.Getenv("TELEGRAM_BOT_TOKEN"), Poller: &tb.LongPoller{Timeout: 10 * time.Second}, // Synchronous: false, - Verbose: true, + Verbose: false, // ParseMode: "", // Reporter: nil, // Client: nil, @@ -55,6 +54,8 @@ func main() { // On reply button pressed (message) b.Handle(&btnHelp, func(m *tb.Message) { + log.Infof("btnHelp: %#v", m) + var ( // Inline buttons. // @@ -86,18 +87,18 @@ func main() { }) b.Handle("/hello", func(m *tb.Message) { - fmt.Printf("message: %#v\n", m) + log.Infof("/hello %#v", m) // b.Send(m.Sender, "Hello World!") }) b.Handle(tb.OnText, func(m *tb.Message) { - fmt.Printf("text: %#v\n", m) + log.Infof("[onText] %#v", m) // all the text messages that weren't // captured by existing handlers }) b.Handle(tb.OnQuery, func(q *tb.Query) { - fmt.Printf("query: %#v\n", q) + log.Infof("[onQuery] %#v", q) // r := &tb.ReplyMarkup{} // r.URL("test", "https://media.tenor.com/images/f176705ae1bb3c457e19d8cd71718ac0/tenor.gif") diff --git a/pkg/interact/command.go b/pkg/interact/command.go new file mode 100644 index 000000000..564c32b36 --- /dev/null +++ b/pkg/interact/command.go @@ -0,0 +1,70 @@ +package interact + +import "strconv" + +// Command is a domain specific language syntax helper +// It's used for helping developer define the state and transition function +type Command struct { + // Name is the command name + Name string + + // StateF is the command handler function + F interface{} + + stateID int + states map[State]State + statesFunc map[State]interface{} + initState, lastState State +} + +func NewCommand(name string, f interface{}) *Command { + c := &Command{ + Name: name, + F: f, + states: make(map[State]State), + statesFunc: make(map[State]interface{}), + initState: State(name + "_" + strconv.Itoa(0)), + } + return c.Next(f) +} + +// Transit defines the state transition that is not related to the last defined state. +func (c *Command) Transit(state1, state2 State, f interface{}) *Command { + c.states[state1] = state2 + c.statesFunc[state1] = f + return c +} + +func (c *Command) NamedNext(n string, f interface{}) *Command { + var curState State + if c.lastState == "" { + curState = State(c.Name + "_" + strconv.Itoa(c.stateID)) + } else { + curState = c.lastState + } + + nextState := State(n) + c.states[curState] = nextState + c.statesFunc[curState] = f + c.lastState = nextState + return c +} + +// Next defines the next state with the transition function from the last defined state. +func (c *Command) Next(f interface{}) *Command { + var curState State + if c.lastState == "" { + curState = State(c.Name + "_" + strconv.Itoa(c.stateID)) + } else { + curState = c.lastState + } + + // generate the next state by the stateID + c.stateID++ + nextState := State(c.Name + "_" + strconv.Itoa(c.stateID)) + + c.states[curState] = nextState + c.statesFunc[curState] = f + c.lastState = nextState + return c +} diff --git a/pkg/interact/interact.go b/pkg/interact/interact.go index 79c2f77a4..76579d7dc 100644 --- a/pkg/interact/interact.go +++ b/pkg/interact/interact.go @@ -1,128 +1,148 @@ package interact import ( + "context" "fmt" "reflect" "strconv" "strings" "text/scanner" - "gopkg.in/tucnak/telebot.v2" + log "github.com/sirupsen/logrus" ) -type Command struct { - // Name is the command name - Name string - - // StateF is the command handler function - F interface{} - - stateID int - states map[string]string - statesFunc map[string]interface{} - initState string +type Reply interface { + Message(message string) + AddButton(text string) + RemoveKeyboard() } -func NewCommand(name string, f interface{}) *Command { - c := &Command{ - Name: name, - F: f, - states: make(map[string]string), - statesFunc: make(map[string]interface{}), - initState: name + "_" + strconv.Itoa(0), - } - return c.Next(f) +type Responder func(reply Reply, response string) error + +type CustomInteraction interface { + Commands(interact *Interact) } -func (c *Command) Next(f interface{}) *Command { - curState := c.Name + "_" + strconv.Itoa(c.stateID) - c.stateID++ - nextState := c.Name + "_" + strconv.Itoa(c.stateID) +type State string - c.states[curState] = nextState - c.statesFunc[curState] = f - return c +const ( + StatePublic State = "public" + StateAuthenticated State = "authenticated" +) + +type Messenger interface { + AddCommand(command string, responder Responder) + Start() } // Interact implements the interaction between bot and message software. type Interact struct { commands map[string]*Command - states map[string]string - statesFunc map[string]interface{} - curState string + states map[State]State + statesFunc map[State]interface{} + + originState, currentState State + + messenger Messenger } func New() *Interact { return &Interact{ - commands: make(map[string]*Command), - states: make(map[string]string), - statesFunc: make(map[string]interface{}), + commands: make(map[string]*Command), + originState: StatePublic, + currentState: StatePublic, + states: make(map[State]State), + statesFunc: make(map[State]interface{}), } } +func (i *Interact) SetOriginState(s State) { + i.originState = s +} + +func (i *Interact) AddCustomInteraction(custom CustomInteraction) { + custom.Commands(i) +} + func (i *Interact) Command(command string, f interface{}) *Command { cmd := NewCommand(command, f) i.commands[command] = cmd return cmd } -func (i *Interact) getNextState(currentState string) (nextState string, end bool) { +func (i *Interact) getNextState(currentState State) (nextState State, final bool) { var ok bool + final = false nextState, ok = i.states[currentState] if ok { - end = false - return nextState, end + // check if it's the final state + if _, hasTransition := i.statesFunc[nextState]; !hasTransition { + final = true + } + + return nextState, final } - end = true - return nextState, end + // state not found, return to the origin state + return i.originState, final } -func (i *Interact) handleResponse(text string) error { +func (i *Interact) setState(s State) { + log.Infof("[interact]: tansiting state from %s -> %s", i.currentState, s) + i.currentState = s +} + +func (i *Interact) handleResponse(text string, ctxObjects ...interface{}) error { args := parseCommand(text) - f, ok := i.statesFunc[i.curState] + f, ok := i.statesFunc[i.currentState] if !ok { - return fmt.Errorf("state function of %s is not defined", i.curState) + return fmt.Errorf("state function of %s is not defined", i.currentState) } - err := parseFuncArgsAndCall(f, args) + err := parseFuncArgsAndCall(f, args, ctxObjects...) if err != nil { return err } - nextState, end := i.getNextState(i.curState) + nextState, end := i.getNextState(i.currentState) if end { + i.setState(i.originState) return nil } - i.curState = nextState + i.setState(nextState) return nil } -func (i *Interact) runCommand(command string, args ...string) error { +func (i *Interact) runCommand(command string, args []string, ctxObjects ...interface{}) error { cmd, ok := i.commands[command] if !ok { return fmt.Errorf("command %s not found", command) } - i.curState = cmd.initState - err := parseFuncArgsAndCall(cmd.F, args) + i.setState(cmd.initState) + err := parseFuncArgsAndCall(cmd.F, args, ctxObjects...) if err != nil { return err } // if we can successfully execute the command, then we can go to the next state. - nextState, end := i.getNextState(i.curState) + nextState, end := i.getNextState(i.currentState) if end { + i.setState(i.originState) return nil } - i.curState = nextState + i.setState(nextState) return nil } +func (i *Interact) SetMessenger(messenger Messenger) { + i.messenger = messenger +} + func (i *Interact) init() error { for n, cmd := range i.commands { _ = n @@ -136,17 +156,29 @@ func (i *Interact) init() error { for s, f := range cmd.statesFunc { i.statesFunc[s] = f } + + // register commands to the service + if i.messenger == nil { + return fmt.Errorf("messenger is not set") + } + + i.messenger.AddCommand(n, func(reply Reply, response string) error { + args := parseCommand(response) + return i.runCommand(n, args, reply) + }) } return nil } -func (i *Interact) HandleTelegramMessage(msg *telebot.Message) { - // For registered commands, will contain the string payload - // msg.Payload - // msg.Text - args := parseCommand(msg.Text) - _ = args +func (i *Interact) Start(ctx context.Context) error { + if err := i.init(); err != nil { + return err + } + + // TODO: use go routine and context + i.messenger.Start() + return nil } func parseCommand(src string) (args []string) { diff --git a/pkg/interact/interact_test.go b/pkg/interact/interact_test.go index 51d289b5c..5797dc6e5 100644 --- a/pkg/interact/interact_test.go +++ b/pkg/interact/interact_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + tb "gopkg.in/tucnak/telebot.v2" ) func Test_parseFuncArgsAndCall_NoErrorFunction(t *testing.T) { @@ -66,11 +67,8 @@ type TestInteraction struct { closePositionTask closePositionTask } -type Reply interface { -} - func (m *TestInteraction) Commands(interact *Interact) { - interact.Command("closePosition", func(reply Reply) error { + interact.Command("/closePosition", func(reply Reply) error { // send symbol options return nil }).Next(func(symbol string) error { @@ -95,29 +93,43 @@ func (m *TestInteraction) Commands(interact *Interact) { } func TestCustomInteraction(t *testing.T) { + b, err := tb.NewBot(tb.Settings{ + Offline: true, + }) + if !assert.NoError(t, err, "should have bot setup without error") { + return + } + globalInteraction := New() + + telegram := &Telegram{ + Bot: b, + Interact: globalInteraction, + } + globalInteraction.SetMessenger(telegram) + testInteraction := &TestInteraction{} testInteraction.Commands(globalInteraction) - err := globalInteraction.init() + err = globalInteraction.init() assert.NoError(t, err) - err = globalInteraction.runCommand("closePosition") + err = globalInteraction.runCommand("/closePosition", []string{}, telegram.newReply()) assert.NoError(t, err) - assert.Equal(t, "closePosition_1", globalInteraction.curState) + assert.Equal(t, State("/closePosition_1"), globalInteraction.currentState) - err = globalInteraction.handleResponse("BTCUSDT") + err = globalInteraction.handleResponse("BTCUSDT", telegram.newReply()) assert.NoError(t, err) - assert.Equal(t, "closePosition_2", globalInteraction.curState) + assert.Equal(t, State("/closePosition_2"), globalInteraction.currentState) - err = globalInteraction.handleResponse("0.20") + err = globalInteraction.handleResponse("0.20", telegram.newReply()) assert.NoError(t, err) - assert.Equal(t, "closePosition_3", globalInteraction.curState) + assert.Equal(t, State("/closePosition_3"), globalInteraction.currentState) - err = globalInteraction.handleResponse("true") + err = globalInteraction.handleResponse("true", telegram.newReply()) assert.NoError(t, err) - assert.Equal(t, "closePosition_4", globalInteraction.curState) + assert.Equal(t, State("public"), globalInteraction.currentState) assert.Equal(t, closePositionTask{ symbol: "BTCUSDT", diff --git a/pkg/interact/telegram.go b/pkg/interact/telegram.go new file mode 100644 index 000000000..bcda4a89f --- /dev/null +++ b/pkg/interact/telegram.go @@ -0,0 +1,89 @@ +package interact + +import ( + "fmt" + + log "github.com/sirupsen/logrus" + "gopkg.in/tucnak/telebot.v2" +) + +type TelegramReply struct { + bot *telebot.Bot + message string + menu *telebot.ReplyMarkup + buttons [][]telebot.Btn +} + +func (r *TelegramReply) Message(message string) { + r.message = message +} + +func (r *TelegramReply) RemoveKeyboard() { + r.menu.ReplyKeyboardRemove = true +} + +func (r *TelegramReply) AddButton(text string) { + var button = r.menu.Text(text) + if len(r.buttons) == 0 { + r.buttons = append(r.buttons, []telebot.Btn{}) + } + r.buttons[len(r.buttons)-1] = append(r.buttons[len(r.buttons)-1], button) +} + +func (r *TelegramReply) build() { + var rows []telebot.Row + for _, buttons := range r.buttons { + rows = append(rows, telebot.Row(buttons)) + } + r.menu.Reply(rows...) +} + +//go:generate callbackgen -type Telegram +type Telegram struct { + Interact *Interact + Bot *telebot.Bot + + // messageCallbacks is used for interact to register its message handler + messageCallbacks []func(reply Reply, message string) +} + +func (b *Telegram) Start() { + b.Bot.Handle(telebot.OnText, func(m *telebot.Message) { + log.Infof("onText: %+v", m) + + reply := b.newReply() + if err := b.Interact.handleResponse(m.Text, reply); err != nil { + log.WithError(err).Errorf("response handling error") + } + + reply.build() + if _, err := b.Bot.Send(m.Sender, reply.message, reply.menu); err != nil { + log.WithError(err).Errorf("message send error") + } + }) + go b.Bot.Start() +} + +func (b *Telegram) AddCommand(command string, responder Responder) { + b.Bot.Handle(command, func(m *telebot.Message) { + reply := b.newReply() + if err := responder(reply, m.Payload); err != nil { + log.WithError(err).Errorf("responder error") + b.Bot.Send(m.Sender, fmt.Sprintf("error: %v", err)) + return + } + + // build up the response objects + reply.build() + if _, err := b.Bot.Send(m.Sender, reply.message, reply.menu); err != nil { + log.WithError(err).Errorf("message send error") + } + }) +} + +func (b *Telegram) newReply() *TelegramReply { + return &TelegramReply{ + bot: b.Bot, + menu: &telebot.ReplyMarkup{ResizeReplyKeyboard: true}, + } +}