diff --git a/cmd/test/main.go b/cmd/test/main.go index fbb89f7..0071a21 100644 --- a/cmd/test/main.go +++ b/cmd/test/main.go @@ -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) { }). diff --git a/tg/bot.go b/tg/bot.go index 8fee2d3..03b1c03 100644 --- a/tg/bot.go +++ b/tg/bot.go @@ -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 diff --git a/tg/keyboard.go b/tg/keyboard.go index 336e841..6a09b56 100644 --- a/tg/keyboard.go +++ b/tg/keyboard.go @@ -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 } + diff --git a/tg/private.go b/tg/private.go index 909efb5..4d4d1ec 100644 --- a/tg/private.go +++ b/tg/private.go @@ -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 } diff --git a/tg/screen.go b/tg/screen.go index 5012382..a2ee8ca 100644 --- a/tg/screen.go +++ b/tg/screen.go @@ -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 -} diff --git a/tg/session.go b/tg/session.go index 31be1f4..5c9f3bd 100644 --- a/tg/session.go +++ b/tg/session.go @@ -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 } diff --git a/tg/widget.go b/tg/widget.go new file mode 100644 index 0000000..cb07b60 --- /dev/null +++ b/tg/widget.go @@ -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 +} +