diff --git a/cmd/test/main.go b/cmd/test/main.go index 1bb729c..db82e7c 100644 --- a/cmd/test/main.go +++ b/cmd/test/main.go @@ -12,20 +12,11 @@ type UserData struct { Counter int } -var startScreenButton = tx.NewButton("🏠 To the start screen"). - ScreenChange("start") +var ( + startScreenButton = tx.NewButton("🏠 To the start screen"). + ScreenChange("start") -var beh = tx.NewBehaviour(). - - // The function will be called every time - // the bot is started. - OnStartFunc(func(c *tx.A) { - c.V = &UserData{} - c.ChangeScreen("start") - }).WithKeyboards( - - // Increment/decrement keyboard. - tx.NewKeyboard("inc/dec").Row( + incDecKeyboard = tx.NewKeyboard("").Row( tx.NewButton("+").ActionFunc(func(c *tx.A) { d := c.V.(*UserData) d.Counter++ @@ -38,49 +29,60 @@ var beh = tx.NewBehaviour(). }), ).Row( startScreenButton, - ), + ) - // The navigational keyboard. - tx.NewKeyboard("nav").Row( - tx.NewButton("Inc/Dec").ScreenChange("inc/dec"), - ).Row( + navKeyboard = tx.NewKeyboard("Choose your interest"). + WithOneTime(true). + Row( + tx.NewButton("Inc/Dec").ScreenChange("inc/dec"), + ).Row( tx.NewButton("Upper case").ScreenChange("upper-case"), tx.NewButton("Lower case").ScreenChange("lower-case"), ).Row( - tx.NewButton("Send location"). - WithSendLocation(true). - ActionFunc(func(c *tx.A) { - var err error - if c.U.Message.Location != nil { - l := c.U.Message.Location - err = c.Sendf( - "Longitude: %f\n"+ - "Latitude: %f\n"+ - "Heading: %d"+ - "", - l.Longitude, - l.Latitude, - l.Heading, - ) - } else { - err = c.Send("Somehow wrong location was sent") - } - if err != nil { - c.Send(err) - } - }), - ), + tx.NewButton("Send location").ScreenChange("send-location"), + ) - tx.NewKeyboard("istart").Row( - tx.NewButton("GoT Github page"). - WithUrl("https://github.com/mojosa-software/got"), - ), + sendLocationKeyboard = tx.NewKeyboard("Press the button to send your location"). + Row( + tx.NewButton("Send location"). + WithSendLocation(true). + ActionFunc(func(c *tx.A) { + var err error + if c.U.Message.Location != nil { + l := c.U.Message.Location + err = c.Sendf( + "Longitude: %f\n"+ + "Latitude: %f\n"+ + "Heading: %d"+ + "", + l.Longitude, + l.Latitude, + l.Heading, + ) + } else { + err = c.Send("Somehow wrong location was sent") + } + if err != nil { + c.Send(err) + } + }), + ).Row( + startScreenButton, + ) // The keyboard to return to the start screen. - tx.NewKeyboard("nav-start").Row( + navToStartKeyboard = tx.NewKeyboard("").Row( startScreenButton, - ), -).WithScreens( + ) +) + +var beh = tx.NewBehaviour(). + OnStartFunc(func(c *tx.A) { + // The function will be called every time + // the bot is started. + c.V = &UserData{} + c.ChangeScreen("start") + }).WithScreens( tx.NewScreen("start"). WithText( "The bot started!"+ @@ -88,8 +90,14 @@ var beh = tx.NewBehaviour(). " understand of how the API works, so just"+ " horse around a bit to guess everything out"+ " by yourself!", - ).Keyboard("nav"). - IKeyboard("istart"), + ).WithKeyboard(navKeyboard). + // The inline keyboard with link to GitHub page. + WithIKeyboard( + tx.NewKeyboard("istart").Row( + tx.NewButton("GoT Github page"). + WithUrl("https://github.com/mojosa-software/got"), + ), + ), tx.NewScreen("inc/dec"). WithText( @@ -98,7 +106,7 @@ var beh = tx.NewBehaviour(). "by saving the counter for each of users "+ "separately. ", ). - Keyboard("inc/dec"). + WithKeyboard(incDecKeyboard). // The function will be called when reaching the screen. ActionFunc(func(c *tx.A) { d := c.V.(*UserData) @@ -107,13 +115,27 @@ var beh = tx.NewBehaviour(). tx.NewScreen("upper-case"). WithText("Type text and the bot will send you the upper case version to you"). - Keyboard("nav-start"). + WithKeyboard(navToStartKeyboard). ActionFunc(mutateMessage(strings.ToUpper)), tx.NewScreen("lower-case"). WithText("Type text and the bot will send you the lower case version"). - Keyboard("nav-start"). + WithKeyboard(navToStartKeyboard). ActionFunc(mutateMessage(strings.ToLower)), + + tx.NewScreen("send-location"). + WithText("Send your location and I will tell where you are!"). + WithKeyboard(sendLocationKeyboard). + WithIKeyboard( + tx.NewKeyboard("").Row( + tx.NewButton("Check"). + WithData("check"). + ActionFunc(func(a *tx.A) { + d := a.V.(*UserData) + a.Sendf("Counter = %d", d.Counter) + }), + ), + ), ).WithCommands( tx.NewCommand("hello"). Desc("sends the 'Hello, World!' message back"). diff --git a/src/tx/action.go b/src/tx/action.go index 96756b8..2f21f3f 100644 --- a/src/tx/action.go +++ b/src/tx/action.go @@ -48,26 +48,23 @@ type A = Arg // Changes screen of user to the Id one. func (c *Arg) ChangeScreen(screenId ScreenId) error { - // Return if it will not change anything. - if c.CurrentScreenId == screenId { - return nil - } - if !c.B.behaviour.ScreenExist(screenId) { return ScreenNotExistErr } - // Stop the reading by sending the nil. + // Stop the reading by sending the nil, + // since we change the screen and + // current goroutine needs to be stopped. if c.readingUpdate { c.updates <- nil } + // Getting the screen and changing to + // then executing its action. screen := c.B.behaviour.Screens[screenId] + c.prevScreen = c.curScreen + c.curScreen = screen screen.Render(c.Context) - - c.Session.ChangeScreen(screenId) - c.KeyboardId = screen.KeyboardId - if screen.Action != nil { c.run(screen.Action, c.U) } diff --git a/src/tx/beh.go b/src/tx/beh.go index 39990f8..08fc683 100644 --- a/src/tx/beh.go +++ b/src/tx/beh.go @@ -3,10 +3,6 @@ package tx // The package implements // behaviour for the Telegram bots. -// The type describes behaviour for the bot in channels. -type ChannelBehaviour struct { -} - // The type describes behaviour for the bot in personal chats. type Behaviour struct { Start Action @@ -35,29 +31,6 @@ func (b *Behaviour) OnStartFunc( return b.WithStart(fn) } -func (b *Behaviour) OnStartChangeScreen( - id ScreenId, -) *Behaviour { - return b.WithStart(ScreenChange(id)) -} - -// The function sets keyboards. -func (b *Behaviour) WithKeyboards( - kbds ...*Keyboard, -) *Behaviour { - for _, kbd := range kbds { - if kbd.Id == "" { - panic("empty keyboard ID") - } - _, ok := b.Keyboards[kbd.Id] - if ok { - panic("duplicate keyboard IDs") - } - b.Keyboards[kbd.Id] = kbd - } - return b -} - // The function sets screens. func (b *Behaviour) WithScreens( screens ...*Screen, @@ -90,21 +63,6 @@ func (b *Behaviour) WithCommands(cmds ...*Command) *Behaviour { return b } -// The function sets group commands. -/*func (b *Behaviour) WithGroupCommands(cmds ...*Command) *Behaviour { - for _, cmd := range cmds { - if cmd.Name == "" { - panic("empty group command name") - } - _, ok := b.GroupCommands[cmd.Name] - if ok { - panic("duplicate group command definition") - } - b.GroupCommands[cmd.Name] = cmd - } - return b -}*/ - // Check whether the screen exists in the behaviour. func (beh *Behaviour) ScreenExist(id ScreenId) bool { _, ok := beh.Screens[id] @@ -128,21 +86,27 @@ type GroupBehaviour struct { Commands GroupCommandMap } +// Returns new empty group behaviour object. func NewGroupBehaviour() *GroupBehaviour { return &GroupBehaviour{ Commands: make(GroupCommandMap), } } +// Sets an Action for initialization on each group connected to the +// group bot. func (b *GroupBehaviour) WithInitAction(a GroupAction) *GroupBehaviour { b.Init = a return b } +// The method reciveies a function to be called on initialization of the +// bot group bot. func (b *GroupBehaviour) InitFunc(fn GroupActionFunc) *GroupBehaviour { return b.WithInitAction(fn) } +// The method sets group commands. func (b *GroupBehaviour) WithCommands( cmds ...*GroupCommand, ) *GroupBehaviour { @@ -158,3 +122,7 @@ func (b *GroupBehaviour) WithCommands( } return b } + +// The type describes behaviour for the bot in channels. +type ChannelBehaviour struct { +} diff --git a/src/tx/button.go b/src/tx/button.go index 113715c..89e1f3d 100644 --- a/src/tx/button.go +++ b/src/tx/button.go @@ -38,6 +38,11 @@ func (btn *Button) WithAction(a Action) *Button { return btn } +func (btn *Button) WithData(dat string) *Button { + btn.Data = dat + return btn +} + // Sets whether the button must send owner's location. func (btn *Button) WithSendLocation(ok bool) *Button { btn.SendLocation = ok diff --git a/src/tx/context.go b/src/tx/context.go index 38e27ca..4c23abc 100644 --- a/src/tx/context.go +++ b/src/tx/context.go @@ -12,20 +12,20 @@ type Context struct { *Session B *Bot updates chan *Update - // Is true if currently reading the Update. readingUpdate bool + + curScreen, prevScreen *Screen } // Goroutie function to handle each user. func (c *Context) handleUpdateChan(updates chan *Update) { var act Action bot := c.B - session := c.Session beh := bot.behaviour c.run(beh.Start, nil) for u := range updates { - screen := bot.behaviour.Screens[session.CurrentScreenId] + screen := c.curScreen // The part is added to implement custom update handling. if u.Message != nil { if u.Message.IsCommand() && !c.readingUpdate { @@ -36,7 +36,13 @@ func (c *Context) handleUpdateChan(updates chan *Update) { } else { } } else { - kbd := beh.Keyboards[screen.KeyboardId] + kbd := screen.Keyboard + if kbd == nil { + if c.readingUpdate { + c.updates <- u + } + continue + } btns := kbd.buttonMap() text := u.Message.Text btn, ok := btns[text] @@ -68,13 +74,24 @@ func (c *Context) handleUpdateChan(updates chan *Update) { if err != nil { panic(err) } - kbd := beh.Keyboards[screen.InlineKeyboardId] + kbd := screen.InlineKeyboard + if kbd == nil { + if c.readingUpdate { + c.updates <- u + } + continue + } + btns := kbd.buttonMap() btn, ok := btns[data] if !ok && c.readingUpdate { c.updates <- u continue } + if !ok { + c.Sendf("%q", btns) + continue + } act = btn.Action } if act != nil { diff --git a/src/tx/errors.go b/src/tx/errors.go index 6126a68..2d5fe43 100644 --- a/src/tx/errors.go +++ b/src/tx/errors.go @@ -10,10 +10,11 @@ type WrongUpdateType struct { } var ( - ScreenNotExistErr = errors.New("screen does not exist") - SessionNotExistErr = errors.New("session does not exist") - KeyboardNotExistErr = errors.New("keyboard does not exist") - NotAvailableErr = errors.New("the context is not available") + ScreenNotExistErr = errors.New("screen does not exist") + SessionNotExistErr = errors.New("session does not exist") + KeyboardNotExistErr = errors.New("keyboard does not exist") + NotAvailableErr = errors.New("the context is not available") + EmptyKeyboardTextErr = errors.New("got empty text for a keyboard") ) func (wut WrongUpdateType) Error() string { diff --git a/src/tx/keyboard.go b/src/tx/keyboard.go index 96b2668..ed3db80 100644 --- a/src/tx/keyboard.go +++ b/src/tx/keyboard.go @@ -23,19 +23,33 @@ type KeyboardId string // The type represents reply keyboard which // is supposed to be showed on a Screen. type Keyboard struct { - Id KeyboardId + // Text to be displayed with the keyboard. + Text string + // Rows to be displayed once the + // keyboard is sent. Rows []ButtonRow + + OneTime bool + Inline bool } type KeyboardMap map[KeyboardId]*Keyboard // Return the new reply keyboard with rows as specified. -func NewKeyboard(id KeyboardId) *Keyboard { +func NewKeyboard(text string) *Keyboard { return &Keyboard{ - Id: id, + Text: text, } } +func (kbd *Keyboard) TelegramMarkup() any { + if kbd.Inline { + return kbd.toTelegramInline() + } + + return kbd.toTelegramReply() +} + // Adds a new button row to the current keyboard. func (kbd *Keyboard) Row(btns ...*Button) *Keyboard { // For empty row. We do not need that. @@ -46,8 +60,8 @@ func (kbd *Keyboard) Row(btns ...*Button) *Keyboard { return kbd } -// Convert the Keyboard to the Telegram API type. -func (kbd *Keyboard) ToTelegram() apix.ReplyKeyboardMarkup { +// Convert the Keyboard to the Telegram API type of reply keyboard. +func (kbd *Keyboard) toTelegramReply() apix.ReplyKeyboardMarkup { rows := [][]apix.KeyboardButton{} for _, row := range kbd.Rows { buttons := []apix.KeyboardButton{} @@ -57,10 +71,14 @@ func (kbd *Keyboard) ToTelegram() apix.ReplyKeyboardMarkup { rows = append(rows, buttons) } + if kbd.OneTime { + return apix.NewOneTimeReplyKeyboard(rows...) + } + return apix.NewReplyKeyboard(rows...) } -func (kbd *Keyboard) ToTelegramInline() apix.InlineKeyboardMarkup { +func (kbd *Keyboard) toTelegramInline() apix.InlineKeyboardMarkup { rows := [][]apix.InlineKeyboardButton{} for _, row := range kbd.Rows { buttons := []apix.InlineKeyboardButton{} @@ -73,6 +91,16 @@ func (kbd *Keyboard) ToTelegramInline() apix.InlineKeyboardMarkup { return apix.NewInlineKeyboardMarkup(rows...) } +func (kbd *Keyboard) WithOneTime(oneTime bool) *Keyboard { + kbd.OneTime = oneTime + return kbd +} + +func (kbd *Keyboard) WithInline(inline bool) *Keyboard { + kbd.Inline = inline + return kbd +} + // Returns the map of buttons. Used to define the Action. func (kbd *Keyboard) buttonMap() ButtonMap { ret := make(ButtonMap) diff --git a/src/tx/screen.go b/src/tx/screen.go index 0de136b..94b8097 100644 --- a/src/tx/screen.go +++ b/src/tx/screen.go @@ -7,24 +7,17 @@ import ( // Unique identifier for the screen. type ScreenId string -// Should be replaced with something that can be -// dinamicaly rendered. (WIP) -type ScreenText string - // Screen statement of the bot. // Mostly what buttons to show. type Screen struct { Id ScreenId - - // Text to be sent to the user when changing to the screen. - Text ScreenText - + // The text to be displayed when the screen is + // reached. + Text string // The keyboard to be sent in the message part. - InlineKeyboardId KeyboardId - + InlineKeyboard *Keyboard // Keyboard to be displayed on the screen. - KeyboardId KeyboardId - + Keyboard *Keyboard // Action called on the reaching the screen. Action Action } @@ -40,18 +33,22 @@ func NewScreen(id ScreenId) *Screen { } // Returns the screen with specified text printing on appearing. -func (s *Screen) WithText(text ScreenText) *Screen { +func (s *Screen) WithText(text string) *Screen { s.Text = text return s } -func (s *Screen) IKeyboard(kbdId KeyboardId) *Screen { - s.InlineKeyboardId = kbdId +func (s *Screen) WithInlineKeyboard(ikbd *Keyboard) *Screen { + s.InlineKeyboard = ikbd return s } -func (s *Screen) Keyboard(kbdId KeyboardId) *Screen { - s.KeyboardId = kbdId +func (s *Screen) WithIKeyboard(ikbd *Keyboard) *Screen { + return s.WithInlineKeyboard(ikbd) +} + +func (s *Screen) WithKeyboard(kbd *Keyboard) *Screen { + s.Keyboard = kbd return s } @@ -64,49 +61,69 @@ func (s *Screen) ActionFunc(a ActionFunc) *Screen { return s.WithAction(a) } -// Rendering the screen text to string to be sent or printed. -func (st ScreenText) String() string { - return string(st) -} - // Renders output of the screen only to the side of the user. func (s *Screen) Render(c *Context) error { id := c.Id.ToTelegram() + kbd := s.Keyboard + iKbd := s.InlineKeyboard - msg := apix.NewMessage(id, s.Text.String()) + var ch [2]apix.Chattable + var txt string - if s.InlineKeyboardId != "" { - kbd, ok := c.B.behaviour.Keyboards[s.InlineKeyboardId] - if !ok { - return KeyboardNotExistErr + // Screen text and inline keyboard. + if s.Text != "" { + txt = s.Text + } else if iKbd != nil { + if iKbd.Text != "" { + txt = iKbd.Text + } else { + // Default to send the keyboard. + txt = ">" } - msg.ReplyMarkup = kbd.ToTelegramInline() } - - _, err := c.B.Send(msg) - if err != nil { - return err - } - - msg = apix.NewMessage(id, ">") - // Checking if we need to resend the keyboard. - if s.KeyboardId != c.KeyboardId { - // Remove keyboard by default. - var tkbd any - tkbd = apix.NewRemoveKeyboard(true) - - // Replace keyboard with the new one. - if s.KeyboardId != "" { - kbd, ok := c.B.behaviour.Keyboards[s.KeyboardId] - if !ok { - return KeyboardNotExistErr + if txt != "" { + msg := apix.NewMessage(id, txt) + if iKbd != nil { + msg.ReplyMarkup = iKbd.toTelegramInline() + } else if kbd != nil { + msg.ReplyMarkup = kbd.toTelegramReply() + if _, err := c.B.Send(msg); err != nil { + return err } - tkbd = kbd.ToTelegram() + return nil + } else { + msg.ReplyMarkup = apix.NewRemoveKeyboard(true) + if _, err := c.B.Send(msg); err != nil { + return err + } + return nil } + ch[0] = msg + } - msg.ReplyMarkup = tkbd - if _, err := c.B.Send(msg); err != nil { - return err + // Screen text and reply keyboard. + txt = "" + if kbd != nil { + if kbd.Text != "" { + txt = kbd.Text + } else { + txt = ">" + } + msg := apix.NewMessage(id, txt) + msg.ReplyMarkup = kbd.toTelegramReply() + ch[1] = msg + } else { + // Removing keyboard if there is none. + msg := apix.NewMessage(id, ">") + msg.ReplyMarkup = apix.NewRemoveKeyboard(true) + ch[1] = msg + } + + for _, m := range ch { + if m != nil { + if _, err := c.B.Send(m); err != nil { + return err + } } } diff --git a/src/tx/session.go b/src/tx/session.go index 66e8d5b..d8a606a 100644 --- a/src/tx/session.go +++ b/src/tx/session.go @@ -12,14 +12,10 @@ func (si SessionId) ToTelegram() int64 { // The type represents current state of // user interaction per each of them. type Session struct { + // Id of the chat of the user. Id SessionId - // Current screen identifier. - CurrentScreenId ScreenId - // ID of the previous screen. - PreviousScreenId ScreenId - // The currently showed on display keyboard inside Action. - KeyboardId KeyboardId - V any + // Custom value for each user. + V any } // Return new empty session with specified user ID. @@ -30,12 +26,6 @@ func NewSession(id SessionId) *Session { } } -// Changes screen of user to the Id one for the session. -func (c *Session) ChangeScreen(screenId ScreenId) { - c.PreviousScreenId = c.CurrentScreenId - c.CurrentScreenId = screenId -} - // The type represents map of sessions using // as key. type SessionMap map[SessionId]*Session