Re-implemented the customizing by the Widget interface.

This commit is contained in:
Andrey Parhomenko 2023-09-09 07:28:06 +03:00
parent 5b00189ea2
commit b2ce5fe2ea
7 changed files with 400 additions and 293 deletions

View file

@ -16,11 +16,31 @@ type UserData struct {
Counter int
}
type MutateMessageWidget struct {
Mutate func(string) string
}
func NewMutateMessageWidget(fn func(string) string) *MutateMessageWidget {
ret := &MutateMessageWidget{}
ret.Mutate = fn
return ret
}
func (w *MutateMessageWidget) Serve(c *tg.Context, updates chan *tg.Update) {
for u := range updates {
if u.Message == nil {
continue
}
text := u.Message.Text
c.Sendf("%s", w.Mutate(text))
}
}
var (
startScreenButton = tg.NewButton("🏠 To the start screen").
ScreenChange("start")
incDecKeyboard = tg.NewInline().Row(
incDecKeyboard = tg.NewReply().Row(
tg.NewButton("+").ActionFunc(func(c *tg.Context) {
d := c.Session.Value.(*UserData)
d.Counter++
@ -38,12 +58,12 @@ var (
navKeyboard = tg.NewReply().
WithOneTime(true).
Row(
tg.NewButton("Inc/Dec").ScreenChange("inc/dec"),
tg.NewButton("Inc/Dec").ScreenChange("start/inc-dec"),
).Row(
tg.NewButton("Upper case").ScreenChange("upper-case"),
tg.NewButton("Lower case").ScreenChange("lower-case"),
tg.NewButton("Upper case").ScreenChange("start/upper-case"),
tg.NewButton("Lower case").ScreenChange("start/lower-case"),
).Row(
tg.NewButton("Send location").ScreenChange("send-location"),
tg.NewButton("Send location").ScreenChange("start/send-location"),
)
sendLocationKeyboard = tg.NewReply().
@ -64,7 +84,7 @@ var (
l.Heading,
)
} else {
_, err = c.Sendf("Somehow wrong location was sent")
_, err = c.Sendf("Somehow location was not sent")
}
if err != nil {
c.Sendf("%q", err)
@ -89,62 +109,69 @@ var beh = tg.NewBehaviour().
WithPreStartFunc(func(c *tg.Context){
c.Sendf("Please, use the /start command to start the bot")
}).WithScreens(
tg.NewScreen("start").
WithText(
"The bot started!"+
" The bot is supposed to provide basic"+
" understand of how the API works, so just"+
" horse around a bit to guess everything out"+
" by yourself!",
).WithReply(navKeyboard).
// The inline keyboard with link to GitHub page.
WithInline(
tg.NewInline().Row(
tg.NewButton("GoT Github page").
WithUrl("https://github.com/mojosa-software/got"),
tg.NewScreen("start", tg.NewPage(
"The bot started!",
).WithInline(
tg.NewInline().Row(
tg.NewButton("GoT Github page").
WithUrl("https://github.com/mojosa-software/got"),
),
).WithReply(
navKeyboard,
),
),
tg.NewScreen("start/inc-dec", tg.NewPage(
"The screen shows how "+
"user separated data works "+
"by saving the counter for each of users "+
"separately. ",
).WithReply(
incDecKeyboard,
).ActionFunc(func(c *tg.Context) {
// The function will be calleb before serving page.
d := c.Session.Value.(*UserData)
c.Sendf("Current counter value = %d", d.Counter)
}),
),
tg.NewScreen("start/upper-case", tg.NewPage(
"Type text and the bot will send you the upper case version to you",
).WithReply(
navToStartKeyboard,
).WithSub(
NewMutateMessageWidget(strings.ToUpper),
),
),
tg.NewScreen("inc/dec").
WithText(
"The screen shows how "+
"user separated data works "+
"by saving the counter for each of users "+
"separately. ",
).
WithReply(&tg.ReplyKeyboard{Keyboard: incDecKeyboard.Keyboard}).
// The function will be called when reaching the screen.
ActionFunc(func(c *tg.Context) {
d := c.Session.Value.(*UserData)
c.Sendf("Current counter value = %d", d.Counter)
}),
tg.NewScreen("start/lower-case", tg.NewPage(
"Type text and the bot will send you the lower case version",
).WithReply(
navToStartKeyboard,
).WithSub(
NewMutateMessageWidget(strings.ToLower),
),
),
tg.NewScreen("upper-case").
WithText("Type text and the bot will send you the upper case version to you").
WithReply(navToStartKeyboard).
ActionFunc(mutateMessage(strings.ToUpper)),
tg.NewScreen("lower-case").
WithText("Type text and the bot will send you the lower case version").
WithReply(navToStartKeyboard).
ActionFunc(mutateMessage(strings.ToLower)),
tg.NewScreen("send-location").
WithText("Send your location and I will tell where you are!").
WithReply(sendLocationKeyboard).
WithInline(
tg.NewInline().Row(
tg.NewButton("Check").
WithData("check").
ActionFunc(func(a *tg.Context) {
d := a.Session.Value.(*UserData)
a.Sendf("Counter = %d", d.Counter)
tg.NewScreen("start/send-location", tg.NewPage(
"Send your location and I will tell where you are!",
).WithReply(
sendLocationKeyboard,
).WithInline(
tg.NewInline().Row(
tg.NewButton(
"Check",
).WithData(
"check",
).ActionFunc(func(c *tg.Context) {
d := c.Session.Value.(*UserData)
c.Sendf("Counter = %d", d.Counter)
}),
),
),
),
).WithCommands(
tg.NewCommand("start").
Desc("start the bot").
Desc("start or restart the bot or move to the start screen").
ActionFunc(func(c *tg.Context){
c.ChangeScreen("start")
}),
@ -156,12 +183,12 @@ var beh = tg.NewBehaviour().
tg.NewCommand("read").
Desc("reads a string and sends it back").
ActionFunc(func(c *tg.Context) {
c.Sendf("Type some text:")
/*c.Sendf("Type some text:")
msg, err := c.ReadTextMessage()
if err != nil {
return
}
c.Sendf("You typed %q", msg)
c.Sendf("You typed %q", msg)*/
}),
tg.NewCommand("image").
Desc("sends a sample image").
@ -177,24 +204,6 @@ var beh = tg.NewBehaviour().
}),
)
func mutateMessage(fn func(string) string) tg.ActionFunc {
return func(c *tg.Context) {
for {
msg, err := c.ReadTextMessage()
if err == tg.NotAvailableErr {
break
} else if err != nil {
panic(err)
}
_, err = c.Sendf("%s", fn(msg))
if err != nil {
panic(err)
}
}
}
}
var gBeh = tg.NewGroupBehaviour().
InitFunc(func(c *tg.GC) {
}).

View file

@ -187,7 +187,6 @@ func (bot *Bot) handlePrivate(updates chan *Update) {
ctx := &context{
Bot: bot,
Session: session,
updates: make(chan *Update),
}
chn := make(chan *Update)
chans[sid] = chn
@ -201,7 +200,6 @@ func (bot *Bot) handlePrivate(updates chan *Update) {
ctx := &context{
Bot: bot,
Session: lsession,
updates: make(chan *Update),
}
chn := make(chan *Update)
chans[sid] = chn

View file

@ -6,9 +6,13 @@ import (
// The general keyboard type used both in Reply and Inline.
type Keyboard struct {
// The action is called if there is no
// defined action for the button.
Action *action
Rows []ButtonRow
}
// The type represents reply keyboards.
type ReplyKeyboard struct {
Keyboard
// If true will be removed after one press.
@ -17,16 +21,18 @@ type ReplyKeyboard struct {
Remove bool
}
// The keyboard to be emdedded into the messages.
// The type represents keyboard to be emdedded into the messages.
type InlineKeyboard struct {
Keyboard
}
// Returns new empty inline keyboard.
func NewInline() *InlineKeyboard {
ret := &InlineKeyboard{}
return ret
}
// Returns new empty reply keyboard.
func NewReply() *ReplyKeyboard {
ret := &ReplyKeyboard {}
return ret
@ -41,6 +47,18 @@ func (kbd *InlineKeyboard) Row(btns ...*Button) *InlineKeyboard {
kbd.Rows = append(kbd.Rows, btns)
return kbd
}
// Set default action for the buttons in keyboard.
func (kbd *InlineKeyboard) WithAction(a Action) *InlineKeyboard {
kbd.Action = newAction(a)
return kbd
}
// Alias to WithAction to simpler define actions.
func (kbd *InlineKeyboard) ActionFunc(fn ActionFunc) *InlineKeyboard {
return kbd.WithAction(fn)
}
// Adds a new button row to the current keyboard.
func (kbd *ReplyKeyboard) Row(btns ...*Button) *ReplyKeyboard {
// For empty row. We do not need that.
@ -51,6 +69,17 @@ func (kbd *ReplyKeyboard) Row(btns ...*Button) *ReplyKeyboard {
return kbd
}
// Set default action for the keyboard.
func (kbd *ReplyKeyboard) WithAction(a Action) *ReplyKeyboard {
kbd.Action = newAction(a)
return kbd
}
// Alias to WithAction for simpler callback declarations.
func (kbd *ReplyKeyboard) ActionFunc(fn ActionFunc) *ReplyKeyboard {
return kbd.WithAction(fn)
}
// Convert the Keyboard to the Telegram API type of reply keyboard.
func (kbd *ReplyKeyboard) ToApi() any {
// Shades everything.
@ -74,6 +103,7 @@ func (kbd *ReplyKeyboard) ToApi() any {
return tgbotapi.NewReplyKeyboard(rows...)
}
// Convert the inline keyboard to markup for the tgbotapi.
func (kbd *InlineKeyboard) ToApi() tgbotapi.InlineKeyboardMarkup {
rows := [][]tgbotapi.InlineKeyboardButton{}
for _, row := range kbd.Rows {
@ -87,18 +117,22 @@ func (kbd *InlineKeyboard) ToApi() tgbotapi.InlineKeyboardMarkup {
return tgbotapi.NewInlineKeyboardMarkup(rows...)
}
// Set if we should remove current keyboard on the user side
// when sending the keyboard.
func (kbd *ReplyKeyboard) WithRemove(remove bool) *ReplyKeyboard {
kbd.Remove = remove
return kbd
}
// Set if the keyboard should be hidden after
// one of buttons is pressede.
func (kbd *ReplyKeyboard) WithOneTime(oneTime bool) *ReplyKeyboard {
kbd.OneTime = oneTime
return kbd
}
// Returns the map of buttons. Used to define the Action.
func (kbd Keyboard) buttonMap() ButtonMap {
func (kbd Keyboard) ButtonMap() ButtonMap {
ret := make(ButtonMap)
for _, vi := range kbd.Rows {
for _, vj := range vi {
@ -108,3 +142,4 @@ func (kbd Keyboard) buttonMap() ButtonMap {
return ret
}

View file

@ -3,17 +3,15 @@ package tg
import (
"fmt"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
//tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
)
type context struct {
Session *Session
// To reach the bot abilities inside callbacks.
Bot *Bot
updates chan *Update
// Is true if currently reading the Update.
readingUpdate bool
curScreen, prevScreen *Screen
widgetUpdates chan *Update
CurScreen, PrevScreen *Screen
}
// The type represents way to interact with user in
@ -29,102 +27,46 @@ func (c *context) handleUpdateChan(updates chan *Update) {
c.run(beh.Init, nil)
}
for u := range updates {
var act Action
screen := c.curScreen
// The part is added to implement custom update handling.
if u.Message != nil {
if !session.Started {
if u.Message.IsCommand() &&
u.Message.Command() == "start" {
// Special treatment for the "/start"
// command.
session.Started = true
cmdName := CommandName("start")
cmd, ok := beh.Commands[cmdName]
if ok {
act = cmd.Action
} else {
// Some usage.
}
} else {
// Prestart handling.
act = preStart
}
} else if u.Message.IsCommand() {
// Command handling.
cmdName := CommandName(u.Message.Command())
if !session.started {
if u.Message.IsCommand() &&
u.Message.Command() == "start" {
// Special treatment for the "/start"
// command.
session.started = true
cmdName := CommandName("start")
cmd, ok := beh.Commands[cmdName]
if ok {
act = cmd.Action
if cmd.Action != nil {
c.run(cmd.Action, u)
}
} else {
// Some usage.
}
} else {
// Simple messages handling.
kbd := screen.Reply
if kbd == nil {
if c.readingUpdate {
c.updates <- u
}
continue
}
btns := kbd.buttonMap()
text := u.Message.Text
btn, ok := btns[text]
if !ok {
if u.Message.Location != nil {
for _, b := range btns {
if b.SendLocation {
btn = b
ok = true
}
}
} else if c.readingUpdate {
// Skipping the update sending it to
// the reading goroutine.
c.updates <- u
continue
}
}
if ok {
act = btn.Action
}
}
} else if u.CallbackQuery != nil && session.Started {
cb := tgbotapi.NewCallback(
u.CallbackQuery.ID,
u.CallbackQuery.Data,
)
data := u.CallbackQuery.Data
_, err := c.Bot.Api.Request(cb)
if err != nil {
panic(err)
}
kbd := screen.Inline
if kbd == nil {
if c.readingUpdate {
c.updates <- u
}
continue
// Prestart handling.
c.run(preStart, u)
}
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 {
c.run(act, u)
continue
}
if u.Message != nil && u.Message.IsCommand() {
// Command handling.
cmdName := CommandName(u.Message.Command())
cmd, ok := beh.Commands[cmdName]
if ok {
if cmd.Action != nil {
c.run(cmd.Action, u)
}
} else {
// Some usage.
}
continue
}
// The standard thing - send messages to widgets.
c.widgetUpdates <- u
}
}
@ -135,29 +77,8 @@ func (c *context) run(a Action, u *Update) {
})
}
// Returns the next update ignoring current screen.
func (c *context) ReadUpdate() (*Update, error) {
c.readingUpdate = true
u := <-c.updates
c.readingUpdate = false
if u == nil {
return nil, NotAvailableErr
}
return u, nil
}
// Returns the next text message that the user sends.
func (c *context) ReadTextMessage() (string, error) {
u, err := c.ReadUpdate()
if err != nil {
return "", err
}
if u.Message == nil {
return "", WrongUpdateType{}
}
return u.Message.Text, nil
func (c *context) Render(v Renderable) ([]*Message, error) {
return c.Bot.Render(c.Session.Id, v)
}
// Sends to the Sendable object.
@ -217,19 +138,36 @@ func (c *Context) ChangeScreen(screenId ScreenId) error {
// 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
}
// if c.readingUpdate {
// c.Updates <- nil
// }
// Getting the screen and changing to
// then executing its action.
// then executing its widget.
screen := c.Bot.behaviour.Screens[screenId]
c.prevScreen = c.curScreen
c.curScreen = screen
c.Bot.Render(c.Session.Id, screen)
if screen.Action != nil {
c.run(screen.Action, c.Update)
c.PrevScreen = c.CurScreen
c.CurScreen = screen
// Making the new channel for the widget.
if c.widgetUpdates != nil {
close(c.widgetUpdates)
}
c.widgetUpdates = make(chan *Update)
if screen.Widget != nil {
// Running the widget if the screen has one.
go screen.Widget.Serve(c, c.widgetUpdates)
} else {
// Skipping updates if there is no
// widget to handle them.
go func() {
for _ = range c.widgetUpdates {}
}()
}
//c.Bot.Render(c.Session.Id, screen)
//if screen.Action != nil {
//c.run(screen.Action, c.Update)
//}
return nil
}

View file

@ -1,9 +1,5 @@
package tg
import (
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
)
// Unique identifier for the screen.
type ScreenId string
@ -11,100 +7,19 @@ type ScreenId string
// Mostly what buttons to show.
type Screen struct {
Id ScreenId
// The text to be displayed when the screen is
// reached.
Text string
// The keyboard to be sent in the message part.
Inline *InlineKeyboard
// Keyboard to be displayed on the screen.
Reply *ReplyKeyboard
// Action called on the reaching the screen.
Action *action
// The widget to run when reaching the screen.
Widget Widget
}
// Map structure for the screens.
type ScreenMap map[ScreenId]*Screen
// Returns the new screen with specified Text and Keyboard.
func NewScreen(id ScreenId) *Screen {
// Returns the new screen with specified name and widget.
func NewScreen(id ScreenId, widget Widget) *Screen {
return &Screen{
Id: id,
Widget: widget,
}
}
// Returns the screen with specified text printing on appearing.
func (s *Screen) WithText(text string) *Screen {
s.Text = text
return s
}
func (s *Screen) WithInline(ikbd *InlineKeyboard) *Screen {
s.Inline= ikbd
return s
}
func (s *Screen) WithReply(kbd *ReplyKeyboard) *Screen {
s.Reply = kbd
return s
}
func (s *Screen) WithAction(a Action) *Screen {
s.Action = newAction(a)
return s
}
func (s *Screen) ActionFunc(a ActionFunc) *Screen {
return s.WithAction(a)
}
func (s *Screen) Render(
sid SessionId, bot *Bot,
) ([]*SendConfig, error) {
cid := sid.ToApi()
reply := s.Reply
inline := s.Inline
ret := []*SendConfig{}
var txt string
// Screen text and inline keyboard.
if s.Text != "" {
txt = s.Text
} else if inline != nil {
// Default to send the keyboard.
txt = ">"
}
if txt != "" {
msgConfig := tgbotapi.NewMessage(cid, txt)
if inline != nil {
msgConfig.ReplyMarkup = inline.ToApi()
} else if reply != nil {
msgConfig.ReplyMarkup = reply.ToApi()
ret = append(ret, &SendConfig{Message: &msgConfig})
return ret, nil
} else {
msgConfig.ReplyMarkup = NewReply().
WithRemove(true).
ToApi()
ret = append(ret, &SendConfig{Message: &msgConfig})
return ret, nil
}
ret = append(ret, &SendConfig{Message: &msgConfig})
}
// Screen text and reply keyboard.
if reply != nil {
msgConfig := tgbotapi.NewMessage(cid, ">")
msgConfig.ReplyMarkup = reply.ToApi()
ret = append(ret, &SendConfig{
Message: &msgConfig,
})
} else {
// Removing keyboard if there is none.
msgConfig := tgbotapi.NewMessage(cid, ">")
msgConfig.ReplyMarkup = NewReply().
WithRemove(true).
ToApi()
ret = append(ret, &SendConfig{Message: &msgConfig})
}
return ret, nil
}

View file

@ -16,7 +16,7 @@ type Session struct {
Id SessionId
// True if the session started.
// (got the '/start' command.
Started bool
started bool
// Custom value for each user.
Value any
}

212
tg/widget.go Normal file
View file

@ -0,0 +1,212 @@
package tg
import (
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
)
// Implementing the interface provides
// ability to build your own widgets,
// aka components.
type Widget interface {
// When the update channel is closed
// widget MUST end its work.
// Mostly made by looping over the
// updates range.
Serve(*Context, chan *Update)
}
// The basic widget to provide keyboard functionality
// without implementing much.
type Page struct {
Text string
SubWidget Widget
Inline *InlineKeyboard
Reply *ReplyKeyboard
Action Action
}
// Return new page with the specified text.
func NewPage(text string) *Page {
ret := &Page{}
ret.Text = text
return ret
}
// Set the inline keyboard.
func (p *Page) WithInline(inline *InlineKeyboard) *Page {
p.Inline = inline
return p
}
// Set the reply keyboard.
func (p *Page) WithReply(reply *ReplyKeyboard) *Page {
p.Reply = reply
return p
}
// Set the action to be run before serving.
func (p *Page) WithAction(a Action) *Page {
p.Action = a
return p
}
// Alias to with action to simpler define actions.
func (p *Page) ActionFunc(fn ActionFunc) *Page {
return p.WithAction(fn)
}
// Set the sub widget that will get the skipped
// updates.
func (p *Page) WithSub(sub Widget) *Page {
p.SubWidget = sub
return p
}
func (p *Page) Serve(
c *Context, updates chan *Update,
) {
msgs, err := c.Render(p)
if err != nil {
panic(err)
}
// The inline message is always returned
// and the reply one is useless in our case.
inlineMsg := msgs[0]
if p.Action != nil {
c.run(p.Action, c.Update)
}
var subUpdates chan *Update
if p.SubWidget != nil {
subUpdates = make(chan *Update)
go p.SubWidget.Serve(c, subUpdates)
defer close(subUpdates)
}
for u := range updates {
var act Action
if u.Message != nil {
text := u.Message.Text
kbd := p.Reply
if kbd == nil {
if subUpdates != nil {
subUpdates <- u
}
continue
}
btns := kbd.ButtonMap()
btn, ok := btns[text]
if !ok {
if u.Message.Location != nil {
for _, b := range btns {
if b.SendLocation {
btn = b
ok = true
}
}
} else if subUpdates != nil {
subUpdates <- u
}
}
if btn != nil {
act = btn.Action
} else if kbd.Action != nil {
act = kbd.Action
}
} else if u.CallbackQuery != nil {
if u.CallbackQuery.Message.MessageID != inlineMsg.MessageID {
if subUpdates != nil {
subUpdates <- u
}
continue
}
cb := tgbotapi.NewCallback(
u.CallbackQuery.ID,
u.CallbackQuery.Data,
)
data := u.CallbackQuery.Data
_, err := c.Bot.Api.Request(cb)
if err != nil {
panic(err)
}
kbd := p.Inline
if kbd == nil {
if subUpdates != nil {
subUpdates <- u
}
continue
}
btns := kbd.ButtonMap()
btn, ok := btns[data]
if !ok {
if subUpdates != nil {
subUpdates <- u
}
continue
}
if btn != nil {
act = btn.Action
} else if kbd.Action != nil {
act = kbd.Action
}
}
if act != nil {
c.run(act, u)
}
}
}
func (s *Page) Render(
sid SessionId, bot *Bot,
) ([]*SendConfig, error) {
cid := sid.ToApi()
reply := s.Reply
inline := s.Inline
ret := []*SendConfig{}
var txt string
// Screen text and inline keyboard.
if s.Text != "" {
txt = s.Text
} else if inline != nil {
// Default to send the keyboard.
txt = ">"
}
if txt != "" {
msgConfig := tgbotapi.NewMessage(cid, txt)
if inline != nil {
msgConfig.ReplyMarkup = inline.ToApi()
} else if reply != nil {
msgConfig.ReplyMarkup = reply.ToApi()
ret = append(ret, &SendConfig{Message: &msgConfig})
return ret, nil
} else {
msgConfig.ReplyMarkup = NewReply().
WithRemove(true).
ToApi()
ret = append(ret, &SendConfig{Message: &msgConfig})
return ret, nil
}
ret = append(ret, &SendConfig{Message: &msgConfig})
}
// Screen text and reply keyboard.
if reply != nil {
msgConfig := tgbotapi.NewMessage(cid, ">")
msgConfig.ReplyMarkup = reply.ToApi()
ret = append(ret, &SendConfig{
Message: &msgConfig,
})
} else {
// Removing keyboard if there is none.
msgConfig := tgbotapi.NewMessage(cid, ">")
msgConfig.ReplyMarkup = NewReply().
WithRemove(true).
ToApi()
ret = append(ret, &SendConfig{Message: &msgConfig})
}
return ret, nil
}