mirror of
https://github.com/c9s/bbgo.git
synced 2024-11-21 14:33:53 +00:00
implement the basic flow of interact
This commit is contained in:
parent
ba4c694179
commit
7eba6b20c9
140
examples/interact/main.go
Normal file
140
examples/interact/main.go
Normal 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)
|
||||
}
|
|
@ -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
70
pkg/interact/command.go
Normal 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
|
||||
}
|
|
@ -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) {
|
||||
|
|
|
@ -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
89
pkg/interact/telegram.go
Normal 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},
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user