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
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")

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
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) {

View File

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

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