implement the basic flow of interact

This commit is contained in:
c9s 2022-01-13 22:15:05 +08:00
parent ba4c694179
commit 7eba6b20c9
6 changed files with 419 additions and 75 deletions

140
examples/interact/main.go Normal file
View File

@ -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)
}

View File

@ -1,12 +1,11 @@
package main package main
import ( import (
"fmt"
"log"
"os" "os"
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
log "github.com/sirupsen/logrus"
tb "gopkg.in/tucnak/telebot.v2" tb "gopkg.in/tucnak/telebot.v2"
) )
@ -33,7 +32,7 @@ func main() {
Token: os.Getenv("TELEGRAM_BOT_TOKEN"), Token: os.Getenv("TELEGRAM_BOT_TOKEN"),
Poller: &tb.LongPoller{Timeout: 10 * time.Second}, Poller: &tb.LongPoller{Timeout: 10 * time.Second},
// Synchronous: false, // Synchronous: false,
Verbose: true, Verbose: false,
// ParseMode: "", // ParseMode: "",
// Reporter: nil, // Reporter: nil,
// Client: nil, // Client: nil,
@ -55,6 +54,8 @@ func main() {
// On reply button pressed (message) // On reply button pressed (message)
b.Handle(&btnHelp, func(m *tb.Message) { b.Handle(&btnHelp, func(m *tb.Message) {
log.Infof("btnHelp: %#v", m)
var ( var (
// Inline buttons. // Inline buttons.
// //
@ -86,18 +87,18 @@ func main() {
}) })
b.Handle("/hello", func(m *tb.Message) { 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.Send(m.Sender, "Hello World!")
}) })
b.Handle(tb.OnText, func(m *tb.Message) { 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 // all the text messages that weren't
// captured by existing handlers // captured by existing handlers
}) })
b.Handle(tb.OnQuery, func(q *tb.Query) { b.Handle(tb.OnQuery, func(q *tb.Query) {
fmt.Printf("query: %#v\n", q) log.Infof("[onQuery] %#v", q)
// r := &tb.ReplyMarkup{} // r := &tb.ReplyMarkup{}
// r.URL("test", "https://media.tenor.com/images/f176705ae1bb3c457e19d8cd71718ac0/tenor.gif") // r.URL("test", "https://media.tenor.com/images/f176705ae1bb3c457e19d8cd71718ac0/tenor.gif")

70
pkg/interact/command.go Normal file
View File

@ -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
}

View File

@ -1,128 +1,148 @@
package interact package interact
import ( import (
"context"
"fmt" "fmt"
"reflect" "reflect"
"strconv" "strconv"
"strings" "strings"
"text/scanner" "text/scanner"
"gopkg.in/tucnak/telebot.v2" log "github.com/sirupsen/logrus"
) )
type Command struct { type Reply interface {
// Name is the command name Message(message string)
Name string AddButton(text string)
RemoveKeyboard()
// StateF is the command handler function
F interface{}
stateID int
states map[string]string
statesFunc map[string]interface{}
initState string
} }
func NewCommand(name string, f interface{}) *Command { type Responder func(reply Reply, response string) error
c := &Command{
Name: name, type CustomInteraction interface {
F: f, Commands(interact *Interact)
states: make(map[string]string),
statesFunc: make(map[string]interface{}),
initState: name + "_" + strconv.Itoa(0),
}
return c.Next(f)
} }
func (c *Command) Next(f interface{}) *Command { type State string
curState := c.Name + "_" + strconv.Itoa(c.stateID)
c.stateID++
nextState := c.Name + "_" + strconv.Itoa(c.stateID)
c.states[curState] = nextState const (
c.statesFunc[curState] = f StatePublic State = "public"
return c StateAuthenticated State = "authenticated"
)
type Messenger interface {
AddCommand(command string, responder Responder)
Start()
} }
// Interact implements the interaction between bot and message software. // Interact implements the interaction between bot and message software.
type Interact struct { type Interact struct {
commands map[string]*Command commands map[string]*Command
states map[string]string states map[State]State
statesFunc map[string]interface{} statesFunc map[State]interface{}
curState string
originState, currentState State
messenger Messenger
} }
func New() *Interact { func New() *Interact {
return &Interact{ return &Interact{
commands: make(map[string]*Command), commands: make(map[string]*Command),
states: make(map[string]string), originState: StatePublic,
statesFunc: make(map[string]interface{}), 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 { func (i *Interact) Command(command string, f interface{}) *Command {
cmd := NewCommand(command, f) cmd := NewCommand(command, f)
i.commands[command] = cmd i.commands[command] = cmd
return 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 var ok bool
final = false
nextState, ok = i.states[currentState] nextState, ok = i.states[currentState]
if ok { if ok {
end = false // check if it's the final state
return nextState, end if _, hasTransition := i.statesFunc[nextState]; !hasTransition {
final = true
} }
end = true return nextState, final
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) args := parseCommand(text)
f, ok := i.statesFunc[i.curState] f, ok := i.statesFunc[i.currentState]
if !ok { 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 { if err != nil {
return err return err
} }
nextState, end := i.getNextState(i.curState) nextState, end := i.getNextState(i.currentState)
if end { if end {
i.setState(i.originState)
return nil return nil
} }
i.curState = nextState i.setState(nextState)
return nil 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] cmd, ok := i.commands[command]
if !ok { if !ok {
return fmt.Errorf("command %s not found", command) return fmt.Errorf("command %s not found", command)
} }
i.curState = cmd.initState i.setState(cmd.initState)
err := parseFuncArgsAndCall(cmd.F, args) err := parseFuncArgsAndCall(cmd.F, args, ctxObjects...)
if err != nil { if err != nil {
return err return err
} }
// if we can successfully execute the command, then we can go to the next state. // 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 { if end {
i.setState(i.originState)
return nil return nil
} }
i.curState = nextState i.setState(nextState)
return nil return nil
} }
func (i *Interact) SetMessenger(messenger Messenger) {
i.messenger = messenger
}
func (i *Interact) init() error { func (i *Interact) init() error {
for n, cmd := range i.commands { for n, cmd := range i.commands {
_ = n _ = n
@ -136,17 +156,29 @@ func (i *Interact) init() error {
for s, f := range cmd.statesFunc { for s, f := range cmd.statesFunc {
i.statesFunc[s] = f 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 return nil
} }
func (i *Interact) HandleTelegramMessage(msg *telebot.Message) { func (i *Interact) Start(ctx context.Context) error {
// For registered commands, will contain the string payload if err := i.init(); err != nil {
// msg.Payload return err
// msg.Text }
args := parseCommand(msg.Text)
_ = args // TODO: use go routine and context
i.messenger.Start()
return nil
} }
func parseCommand(src string) (args []string) { func parseCommand(src string) (args []string) {

View File

@ -7,6 +7,7 @@ import (
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
tb "gopkg.in/tucnak/telebot.v2"
) )
func Test_parseFuncArgsAndCall_NoErrorFunction(t *testing.T) { func Test_parseFuncArgsAndCall_NoErrorFunction(t *testing.T) {
@ -66,11 +67,8 @@ type TestInteraction struct {
closePositionTask closePositionTask closePositionTask closePositionTask
} }
type Reply interface {
}
func (m *TestInteraction) Commands(interact *Interact) { func (m *TestInteraction) Commands(interact *Interact) {
interact.Command("closePosition", func(reply Reply) error { interact.Command("/closePosition", func(reply Reply) error {
// send symbol options // send symbol options
return nil return nil
}).Next(func(symbol string) error { }).Next(func(symbol string) error {
@ -95,29 +93,43 @@ func (m *TestInteraction) Commands(interact *Interact) {
} }
func TestCustomInteraction(t *testing.T) { 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() globalInteraction := New()
telegram := &Telegram{
Bot: b,
Interact: globalInteraction,
}
globalInteraction.SetMessenger(telegram)
testInteraction := &TestInteraction{} testInteraction := &TestInteraction{}
testInteraction.Commands(globalInteraction) testInteraction.Commands(globalInteraction)
err := globalInteraction.init() err = globalInteraction.init()
assert.NoError(t, err) assert.NoError(t, err)
err = globalInteraction.runCommand("closePosition") err = globalInteraction.runCommand("/closePosition", []string{}, telegram.newReply())
assert.NoError(t, err) 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.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.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.NoError(t, err)
assert.Equal(t, "closePosition_4", globalInteraction.curState) assert.Equal(t, State("public"), globalInteraction.currentState)
assert.Equal(t, closePositionTask{ assert.Equal(t, closePositionTask{
symbol: "BTCUSDT", symbol: "BTCUSDT",

89
pkg/interact/telegram.go Normal file
View File

@ -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},
}
}