diff --git a/beh.go b/beh.go index 121a034..c40c8f0 100644 --- a/beh.go +++ b/beh.go @@ -1,13 +1,10 @@ package tg -// The package implements -// behaviour for the Telegram bots. - // The type describes behaviour for the bot in personal chats. type Behaviour struct { Root Component - Init Action - Screens ScreenMap + Init Action + Screens ScreenMap } // Returns new empty behaviour. @@ -19,43 +16,21 @@ func NewBehaviour() *Behaviour { // The Action will be called on session creation, // not when starting or restarting the bot with the Start Action. -func (b *Behaviour) WithInit(a Action) *Behaviour { +func (b *Behaviour) SetInit(a Action) *Behaviour { b.Init = a return b } -// Alias to WithInit to simplify behaviour definitions. -func (b *Behaviour) WithInitFunc( - fn ActionFunc, -) *Behaviour { - return b.WithInit(fn) -} - -func (b *Behaviour) WithRootNode(node *RootNode) *Behaviour { +// Sets the root node of the Behaviour. +// Mostly used for commands and such stuff. +func (b *Behaviour) SetRootNode(node *RootNode) *Behaviour { b.Screens = node.ScreenMap() return b } -// The function sets screens. -/*func (b *Behaviour) WithScreens( - screens ...*Screen, -) *Behaviour { - for _, screen := range screens { - if screen.Id == "" { - panic("empty screen ID") - } - _, ok := b.Screens[screen.Id] - if ok { - panic("duplicate keyboard IDs") - } - b.Screens[screen.Id] = screen - } - return b -}*/ - // The function sets as the standard root widget CommandWidget // and its commands.. -func (b *Behaviour) WithRoot(root Component) *Behaviour { +func (b *Behaviour) SetRootWidget(root Component) *Behaviour { b.Root = root return b } diff --git a/bot.go b/bot.go index fb6af85..d5cce6d 100644 --- a/bot.go +++ b/bot.go @@ -38,7 +38,7 @@ func NewBot(token string) (*Bot, error) { }, nil } -func (bot *Bot) Debug(debug bool) *Bot { +func (bot *Bot) SetDebug(debug bool) *Bot { bot.api.Debug = debug return bot } @@ -56,27 +56,26 @@ func (bot *Bot) Me() User { // SessionId represents both for chat IDs. func (bot *Bot) Send( sid SessionId, v Sendable, -) (Message, error) { +) (*Message, error) { config := v.SendConfig(sid, bot) if config.Error != nil { - return Message{}, config.Error + return nil, config.Error } msg, err := bot.api.Send(config.ToApi()) if err != nil { - return Message{}, err + return nil, err } - v.SetMessage(msg) - return msg, nil + v.SetMessage(&msg) + return &msg, nil } func (bot *Bot) Sendf( sid SessionId, format string, v ...any, -) (Message, error){ - msg := Messagef(format, v...) +) (*Message, error){ return bot.Send( sid, - &msg, + Messagef(format, v...), ) } @@ -93,20 +92,29 @@ func (bot *Bot) SendRaw( // Get session by its ID. Can be used for any scope // including private, group and channel. -func (bot *Bot) GetSession( +func (bot *Bot) GotSession( sid SessionId, ) (*Session, bool) { session, ok := bot.sessions[sid] return session, ok } -func (b *Bot) WithBehaviour(beh *Behaviour) *Bot { +func (bot *Bot) SetData(v any) *Bot { + bot.data = v + return bot +} + +func (bot *Bot) Data() any { + return bot.data +} + +func (b *Bot) SetBehaviour(beh *Behaviour) *Bot { b.behaviour = beh b.sessions = make(SessionMap) return b } -func (b *Bot) WithSessions(sessions SessionMap) *Bot { +func (b *Bot) SetSessions(sessions SessionMap) *Bot { b.sessions = sessions return b } @@ -140,7 +148,7 @@ func (bot *Bot) SetCommands( } sort.Strings(names) - cmds := []*Command{} + cmds := []Command{} for _, name := range names { cmds = append( cmds, @@ -202,7 +210,7 @@ func (bot *Bot) Run() error { go bot.handleGroup(chn) }*/ - me, _ := bot.Api.GetMe() + me, _ := bot.Api().GetMe() bot.me = me for up := range updates { u := Update{ @@ -216,6 +224,7 @@ func (bot *Bot) Run() error { } chn, ok := handles[fromChat.Type] + // Skipping non existent scope. if !ok { continue } @@ -232,64 +241,30 @@ func (bot *Bot) handlePrivate(updates chan Update) { var sid SessionId for u := range updates { sid = SessionId(u.FromChat().ID) - ctx, ctxOk := bot.contexts[sid] - if u.Message != nil && !ctxOk { + session, sessionOk := bot.sessions[sid] - session, sessionOk := bot.sessions[sid] - if !sessionOk { - // Creating session if we have none. - session = bot.sessions.Add(sid, PrivateSessionScope) - } - session = bot.sessions[sid] + if u.Message != nil && !sessionOk { + // Creating session if we have none + // but only on text messages. + session = bot.sessions.Add(bot, sid, PrivateSessionScope) - // Create context on any message - // if we have no one. - ctx = &context{ - Bot: bot, - Session: session, - updates: NewUpdateChan(), - } - if !ctxOk { - bot.contexts[sid] = ctx - } - - go Context{ + // Creating the root context + // that takes updates directly from + // the session. + rootContext := Context{ session: session, - bot: bot, - Update: u, - input: ctx.updates, - }.serve() - ctx.session.updates.Send(u) + update: u, + input: session.updates, + } + go rootContext.serve() + rootContext.input.Send(u) + continue } - if ctxOk { - ctx.updates.Send(u) + if sessionOk { + session.updates.Send(u) } } } -/* -func (bot *Bot) handleGroup(updates chan *Update) { - var sid SessionId - chans := make(map[SessionId]chan *Update) - for u := range updates { - sid = SessionId(u.FromChat().ID) - // If no session add new. - if _, ok := bot.groupSessions[sid]; !ok { - bot.groupSessions.Add(sid) - session := bot.groupSessions[sid] - ctx := &groupContext{ - Bot: bot, - Session: session, - updates: make(chan *Update), - } - chn := make(chan *Update) - chans[sid] = chn - go ctx.handleUpdateChan(chn) - } - chn := chans[sid] - chn <- u - } -} -*/ diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..ad0cf18 --- /dev/null +++ b/build.sh @@ -0,0 +1,3 @@ +#!/bin/sh +# +go build -o ./exe/test ./cmd/test diff --git a/button.go b/button.go index 4573bb8..d921ccd 100644 --- a/button.go +++ b/button.go @@ -14,31 +14,37 @@ type Button struct { Url string SendLocation bool Action Action + // Used to skip buttons in generating by functions. + Valid bool } type ButtonMap map[string]Button -// Returns the only location button in the map. -func (btnMap ButtonMap) LocationButton() *Button { +// Returns the only location button in the map and if it is there at all. +// The location map must be the ONLY one. + +func (btnMap ButtonMap) LocationButton() (Button, bool) { for _, btn := range btnMap { if btn.SendLocation { - return btn + return btn, true } } - return nil + return Button{}, false } // Represents the reply button row. -type ButtonRow []*Button +type ButtonRow []Button // Returns new button with the specified text and no action. func Buttonf(format string, v ...any) Button { - return &Button{ + return Button{ Text: fmt.Sprintf(format, v...), + Valid: true, } } // Randomize buttons data to make the key unique. +// No guaranties about collisions though. func (btn Button) Rand() Button { rData := make([]byte, 8) rand.Read(rData) @@ -72,10 +78,16 @@ func (btn Button) WithSendLocation(ok bool) Button { return btn } -func (btn Button) Go(pth Path, args ...any) Button { +func (btn Button) Go(pth Path) Button { return btn.WithAction(ScreenGo{ Path: pth, - Args: args, + }) +} + +func (btn Button) GoWithArg(pth Path, arg any) Button { + return btn.WithAction(ScreenGo{ + Path: pth, + Arg: arg, }) } @@ -89,11 +101,17 @@ func (btn Button) ToTelegram() apix.KeyboardButton { func (btn Button) ToTelegramInline() apix.InlineKeyboardButton { if btn.Data != "" { - return apix.NewInlineKeyboardButtonData(btn.Text, btn.Data) + return apix.NewInlineKeyboardButtonData( + btn.Text, + btn.Data, + ) } if btn.Url != "" { - return apix.NewInlineKeyboardButtonURL(btn.Text, btn.Url) + return apix.NewInlineKeyboardButtonURL( + btn.Text, + btn.Url, + ) } // If no match then return the data one with data the same as the text. @@ -102,9 +120,6 @@ func (btn Button) ToTelegramInline() apix.InlineKeyboardButton { // Return the key of the button to identify it by messages and callbacks. func (btn Button) Key() string { - if btn == nil { - return "" - } if btn.Data != "" { return btn.Data } @@ -113,6 +128,7 @@ func (btn Button) Key() string { return btn.Text } -func NewButtonRow(btns ...*Button) ButtonRow { +func NewButtonRow(btns ...Button) ButtonRow { return btns } + diff --git a/cmd/test/cmd.go b/cmd/test/cmd.go new file mode 100644 index 0000000..4f78c11 --- /dev/null +++ b/cmd/test/cmd.go @@ -0,0 +1,87 @@ +package main + +import ( + "vultras.su/core/tg" + "os" +) + +var UsageAction = tg.Func(func(c tg.Context){ + c.Sendf( + "There is no such command %q", + c.CallbackUpdate().Message.Command(), + ) +}) + +var PreStartAction = tg.Func(func(c tg.Context){ + c.Sendf("Please, use /start ") +}) + +var BotCommands = []tg.Command{ + tg.NewCommand( + "start", + "start or restart the bot or move to the start screen", + ).Go(StartAbsPath), + tg.NewCommand( + "info", + "info desc", + ).WithAction(tg.Func(func(c tg.Context) { + c.SendfHTML(`cockcock die`) + })), + tg.NewCommand("hello", "sends the 'Hello, World!' message back"). + WithAction(tg.Func(func(c tg.Context) { + c.Sendf("Hello, World!") + })), + tg.NewCommand("read", "reads a string and sends it back"). + WithWidget( + tg.Func(func(c tg.Context) { + str := c.ReadString("Type a string and I will send it back") + if str == "" { + return + } + c.Sendf2("You typed `%s`", str) + }), + ), + tg.NewCommand("cat", "sends a sample image of cat from the server storage"). + WithAction(tg.Func(func(c tg.Context) { + f, err := os.Open("media/cat.jpg") + if err != nil { + c.Sendf("err: %s", err) + return + } + defer f.Close() + photo := tg.NewFile(f).Photo().Name("cat.jpg").Caption("A cat!") + c.Send(photo) + })), + tg.NewCommand("document", "sends a sample text document"). + WithAction(tg.Func(func(c tg.Context) { + f, err := os.Open("media/hello.txt") + if err != nil { + c.Sendf("err: %s", err) + return + } + defer f.Close() + doc := tg.NewFile(f).Document().Name("hello.txt").Caption("The document") + c.Send(doc) + })), + tg.NewCommand("botname", "get the bot name"). + WithAction(tg.Func(func(c tg.Context) { + bd := c.Bot().Data().(*BotData) + c.Sendf("My name is %q", bd.Name) + })), + tg.NewCommand("history", "print go history"). + WithAction(tg.Func(func(c tg.Context) { + c.Sendf("%q", c.PathHistory()) + })), + tg.NewCommand( + "washington", + "send location of the Washington", + ).WithAction(tg.Func(func(c tg.Context) { + c.Sendf("Washington location") + c.Send( + tg.Messagef("").Location( + 47.751076, -120.740135, + ), + ) + })), +} + diff --git a/cmd/test/incdec.go b/cmd/test/incdec.go new file mode 100644 index 0000000..29a5b66 --- /dev/null +++ b/cmd/test/incdec.go @@ -0,0 +1,66 @@ +package main + +import ( + "vultras.su/core/tg" + "fmt" +) + +const ( + IncDecPath tg.Path = "inc-dec" +) + +var IncDecWidget = tg.RenderFunc(func(c tg.Context) tg.UI { + var ( + kbd *tg.InlineCompo + //cntMsg *tg.MessageCompo + inline, std, onlyInc, onlyDec tg.Inline + ) + + d := ExtractSessionData(c) + format := "Press the buttons to increment and decrement.\n" + + "Current counter value = %d" + + incBtn := tg.Buttonf("+").WithAction(tg.Func(func(c tg.Context) { + d.Counter++ + kbd.Text = fmt.Sprintf(format, d.Counter) + if d.Counter == 5 { + kbd.Inline = onlyDec + } else { + kbd.Inline = std + } + kbd.Update(c) + })) + decBtn := tg.Buttonf("-").WithAction(tg.Func(func(c tg.Context) { + d.Counter-- + kbd.Text = fmt.Sprintf(format, d.Counter) + if d.Counter == -5 { + kbd.Inline = onlyInc + } else { + kbd.Inline = std + } + kbd.Update(c) + //c.Sendf("%d", d.Counter) + })) + + onlyInc = tg.NewKeyboard().Row(incBtn).Inline() + onlyDec = tg.NewKeyboard().Row(decBtn).Inline() + std = tg.NewKeyboard().Row(incBtn, decBtn).Inline() + + if d.Counter == 5 { + inline = onlyDec + } else if d.Counter == -5 { + inline = onlyInc + } else { + inline = std + } + + kbd = tg.Messagef(format, d.Counter).Inline(inline) + + return tg.UI{ + kbd, + tg.Messagef("Use the reply keyboard to get back").Reply( + BackKeyboard.Reply(), + ), + } +}) + diff --git a/cmd/test/keyboard.go b/cmd/test/keyboard.go new file mode 100644 index 0000000..56dbff8 --- /dev/null +++ b/cmd/test/keyboard.go @@ -0,0 +1,30 @@ +package main + +import ( + "vultras.su/core/tg" +) + +var HomeButton = tg.Buttonf("Home").Go("/") +var BackButton = tg.Buttonf("Back").Go("-") +var BackKeyboard = tg.NewKeyboard().Row( + BackButton, +) + +var SendLocationKeyboard = tg.NewKeyboard().Row( + tg.Buttonf("Send location"). + WithSendLocation(true). + WithAction(tg.Func(func(c tg.Context) { + l := c.CallbackUpdate().Message.Location + c.Sendf( + "Longitude: %f\n"+ + "Latitude: %f\n"+ + "Heading: %d"+ + "", + l.Longitude, + l.Latitude, + l.Heading, + ) + })), +).Row( + BackButton, +).Reply() diff --git a/cmd/test/location.go b/cmd/test/location.go new file mode 100644 index 0000000..b2dd43c --- /dev/null +++ b/cmd/test/location.go @@ -0,0 +1,34 @@ +package main + +import ( + "vultras.su/core/tg" +) + +const ( + LocationPath tg.Path = "location" +) + +var LocationWidget = tg.RenderFunc(func(c tg.Context) tg.UI { + return tg.UI{ + tg.Messagef( + "Press the button to display your counter", + ).Inline( + tg.NewKeyboard().Row( + tg.Buttonf( + "Check", + ).WithData( + "check", + ).WithAction(tg.Func(func(c tg.Context) { + d := ExtractSessionData(c) + c.Sendf("Counter = %d", d.Counter) + })), + ).Inline(), + ), + + tg.Messagef( + "Press the button to send your location!", + ).Reply( + SendLocationKeyboard, + ), + } +}) diff --git a/cmd/test/main.go b/cmd/test/main.go index dc32121..047615f 100644 --- a/cmd/test/main.go +++ b/cmd/test/main.go @@ -3,8 +3,6 @@ package main import ( "log" "os" - "strings" - "fmt" "vultras.su/core/tg" ) @@ -17,391 +15,72 @@ type SessionData struct { Counter int } -type MutateMessageWidget struct { - Mutate func(string) string +func ExtractSessionData(c tg.Context) *SessionData { + return c.SessionData().(*SessionData) } -func NewMutateMessageWidget(fn func(string) string) *MutateMessageWidget { - ret := &MutateMessageWidget{} - ret.Mutate = fn - return ret -} - -func (w *MutateMessageWidget) Serve(c *tg.Context) { - args, ok := c.Arg().([]any) - if ok { - for _, arg := range args { - c.Sendf("%v", arg) - } - } - for u := range c.Input() { - text := u.Message.Text - _, err := c.Sendf2("%s", w.Mutate(text)) - if err != nil { - c.Sendf("debug: %q", err) - } - } -} - -func (w *MutateMessageWidget) Filter(u *tg.Update) bool { - if u.Message == nil { - return true - } - return false -} - -func ExtractSessionData(c *tg.Context) *SessionData { - return c.Session.Data.(*SessionData) -} - -var ( - homeButton = tg.NewButton("Home").Go("/") - backButton = tg.NewButton("Back").Go("-") - backKeyboard = tg.NewKeyboard().Row( - backButton, - ) - - sendLocationKeyboard = tg.NewKeyboard().Row( - tg.NewButton("Send location"). - WithSendLocation(true). - ActionFunc(func(c *tg.Context) { - l := c.Message.Location - c.Sendf( - "Longitude: %f\n"+ - "Latitude: %f\n"+ - "Heading: %d"+ - "", - l.Longitude, - l.Latitude, - l.Heading, - ) - }), - ).Row( - backButton, - ).Reply() -) - -var beh = tg.NewBehaviour(). - WithInitFunc(func(c *tg.Context) { - // The session initialization. - c.Session.Data = &SessionData{} - }).WithRootNode(tg.NewRootNode( +var beh = tg.NewBehaviour().SetInit(tg.Func(func(c tg.Context) { + // The session initialization. + c.SetSessionData(&SessionData{}) +})).SetRootNode(tg.NewRootNode( // The "/" widget. - tg.RenderFunc(func(c *tg.Context) tg.UI { - return tg.UI{ - tg.NewMessage(fmt.Sprintf( - fmt.Sprint( - "Hello, %s!\n", - "The testing bot started!\n", - "You can see the basics of usage in the ", - "cmd/test/main.go file!", - ), - c.SentFrom().UserName, - )).Inline( - tg.NewKeyboard().Row( - tg.NewButton("GoT Github page"). - WithUrl("https://github.com/mojosa-software/got"), - ).Inline(), - ), - - tg.NewMessage("Choose your interest").Reply( - tg.NewKeyboard().Row( - tg.NewButton("Inc/Dec").Go("/inc-dec"), - ).Row( - tg.NewButton("Mutate messages").Go("/mutate-messages"), - ).Row( - tg.NewButton("Send location").Go("/send-location"), - ).Row( - tg.NewButton("Dynamic panel").Go("panel"), - ).Reply(), - ), - - tg.Func(func(c *tg.Context) { - for u := range c.Input() { - if u.EditedMessage != nil { - c.Sendf2("The new message is `%s`", u.EditedMessage.Text) - } - } - }), - } - }), + StartWidget, tg.NewNode( - "panel", - tg.RenderFunc(func(c *tg.Context) tg.UI { - var ( - n = 0 - ln = 4 - panel *tg.PanelCompo - ) - - panel = tg.NewMessage( - "Some panel", - ).Panel(c, tg.RowserFunc(func(c *tg.Context) []tg.ButtonRow { - btns := []tg.ButtonRow{ - tg.ButtonRow{tg.NewButton("Static shit")}, - } - for i := 0; i < ln; i++ { - num := 1 + n*ln + i - btns = append(btns, tg.ButtonRow{ - tg.NewButton("%d", num).WithAction(tg.Func(func(c *tg.Context) { - c.Sendf("%d", num*num) - })), - tg.NewButton("%d", num*num), - }) - } - btns = append(btns, tg.ButtonRow{ - tg.NewButton("Prev").WithAction(tg.ActionFunc(func(c *tg.Context) { - n-- - panel.Update(c) - })), - tg.NewButton("Next").WithAction(tg.ActionFunc(func(c *tg.Context) { - n++ - panel.Update(c) - })), - }) - - return btns - })) - - return tg.UI{ - panel, - tg.NewMessage("").Reply( - backKeyboard.Reply(), - ), - } - }), + PanelPath, + PanelWidget, ), tg.NewNode( - "mutate-messages", tg.RenderFunc(func(c *tg.Context) tg.UI { - return tg.UI{ - tg.NewMessage( - "Choose the function to mutate string", - ).Reply( - tg.NewKeyboard().Row( - tg.NewButton("Upper case").Go("upper-case"), - tg.NewButton("Lower case").Go("lower-case"), - tg.NewButton("Escape chars").Go("escape"), - ).Row( - backButton, - ).Reply(), - ), - } - }), + MutateMessagesPath, + MutateMessagesWidget, + tg.NewNode( - "upper-case", tg.RenderFunc(func(c *tg.Context) tg.UI { - return tg.UI{ - tg.NewMessage( - "Type a string and the bot will convert it to upper case", - ).Reply( - backKeyboard.Reply(), - ), - NewMutateMessageWidget(strings.ToUpper), - } - }), + UpperCasePath, + MutateMessagesToUpperCaseWidget, ), tg.NewNode( - "lower-case", tg.RenderFunc(func(c *tg.Context) tg.UI { - return tg.UI{ - tg.NewMessage( - "Type a string and the bot will convert it to lower case", - ).Reply( - backKeyboard.Reply(), - ), - NewMutateMessageWidget(strings.ToLower), - } - }), + LowerCasePath, + MutateMessagesToLowerCaseWidget, ), tg.NewNode( - "escape", tg.RenderFunc(func(c *tg.Context) tg.UI { - return tg.UI{ - tg.NewMessage( - "Type a string and the bot will escape characters in it", - ).Reply( - backKeyboard.Reply(), - ), - NewMutateMessageWidget(tg.Escape2), - } - }), + EscapePath, + MutateMessagesEscapeWidget, ), ), tg.NewNode( - "inc-dec", tg.RenderFunc(func(c *tg.Context) tg.UI { - var ( - kbd *tg.InlineCompo - //cntMsg *tg.MessageCompo - inline, std, onlyInc, onlyDec *tg.Inline - ) - - d := ExtractSessionData(c) - format := "Press the buttons to increment and decrement.\n" + - "Current counter value = %d" - - incBtn := tg.NewButton("+").ActionFunc(func(c *tg.Context) { - d.Counter++ - kbd.Text = fmt.Sprintf(format, d.Counter) - if d.Counter == 5 { - kbd.Inline = onlyDec - } else { - kbd.Inline = std - } - kbd.Update(c) - }) - decBtn := tg.NewButton("-").ActionFunc(func(c *tg.Context) { - d.Counter-- - kbd.Text = fmt.Sprintf(format, d.Counter) - if d.Counter == -5 { - kbd.Inline = onlyInc - } else { - kbd.Inline = std - } - kbd.Update(c) - //c.Sendf("%d", d.Counter) - }) - - onlyInc = tg.NewKeyboard().Row(incBtn).Inline() - onlyDec = tg.NewKeyboard().Row(decBtn).Inline() - std = tg.NewKeyboard().Row(incBtn, decBtn).Inline() - - if d.Counter == 5 { - inline = onlyDec - } else if d.Counter == -5 { - inline = onlyInc - } else { - inline = std - } - - kbd = tg.NewMessage( - fmt.Sprintf(format, d.Counter), - ).Inline(inline) - - return tg.UI{ - kbd, - tg.NewMessage("Use the reply keyboard to get back").Reply( - backKeyboard.Reply(), - ), - } - }), + IncDecPath, + IncDecWidget, ), tg.NewNode( - "send-location", tg.RenderFunc(func(c *tg.Context) tg.UI { - return tg.UI{ - tg.NewMessage( - "Press the button to display your counter", - ).Inline( - tg.NewKeyboard().Row( - tg.NewButton( - "Check", - ).WithData( - "check", - ).WithAction(tg.Func(func(c *tg.Context) { - d := ExtractSessionData(c) - c.Sendf("Counter = %d", d.Counter) - })), - ).Inline(), - ), - - tg.NewMessage( - "Press the button to send your location!", - ).Reply( - sendLocationKeyboard, - ), - } - }), + LocationPath, + LocationWidget, ), -)).WithRoot(tg.NewCommandCompo(). - WithUsage(tg.Func(func(c *tg.Context) { - c.Sendf("There is no such command %q", c.Message.Command()) - })).WithPreStart(tg.Func(func(c *tg.Context) { - c.Sendf("Please, use /start ") -})).WithCommands( - tg.NewCommand("info", "info desc"). - ActionFunc(func(c *tg.Context) { - c.SendfHTML(`cockcock die`) - }), - tg.NewCommand( - "start", - "start or restart the bot or move to the start screen", - ).Go("/"), - tg.NewCommand("hello", "sends the 'Hello, World!' message back"). - ActionFunc(func(c *tg.Context) { - c.Sendf("Hello, World!") - }), - tg.NewCommand("read", "reads a string and sends it back"). - WithWidget( - tg.Func(func(c *tg.Context) { - str := c.ReadString("Type a string and I will send it back") - c.Sendf2("You typed `%s`", str) - }), - ), - tg.NewCommand("cat", "sends a sample image of cat"). - ActionFunc(func(c *tg.Context) { - f, err := os.Open("media/cat.jpg") - if err != nil { - c.Sendf("err: %s", err) - return - } - defer f.Close() - photo := tg.NewFile(f).Photo().Name("cat.jpg").Caption("A cat!") - c.Send(photo) - }), - tg.NewCommand("document", "sends a sample text document"). - ActionFunc(func(c *tg.Context) { - f, err := os.Open("media/hello.txt") - if err != nil { - c.Sendf("err: %s", err) - return - } - defer f.Close() - doc := tg.NewFile(f).Document().Name("hello.txt").Caption("The document") - c.Send(doc) - }), - tg.NewCommand("botname", "get the bot name"). - WithAction(tg.Func(func(c *tg.Context) { - bd := c.Bot.Data.(*BotData) - c.Sendf("My name is %q", bd.Name) - })), - tg.NewCommand("dynamic", "check of the dynamic work"). - WithWidget(tg.Func(func(c *tg.Context) { - })), - tg.NewCommand("history", "print go history"). - WithAction(tg.Func(func(c *tg.Context) { - c.Sendf("%q", c.History()) - })), - tg.NewCommand("washington", "send location of the Washington"). - WithAction(tg.Func(func(c *tg.Context) { - c.Sendf("Washington location") - c.Send( - tg.NewMessage("").Location( - 47.751076, -120.740135, - ), - ) - })), - tg.NewCommand("invoice", "invoice check"). - WithAction(tg.Func(func(c *tg.Context) { - })), +)).SetRootWidget(tg.NewCommandCompo().SetUsage( + UsageAction, +).SetPreStart( + PreStartAction, +).SetCommands( + BotCommands..., )) func main() { - log.Println(beh.Screens) token := os.Getenv("BOT_TOKEN") bot, err := tg.NewBot(token) if err != nil { log.Panic(err) } - bot = bot. - WithBehaviour(beh). - Debug(true) + bot = bot.SetBehaviour(beh) + //SetDebug(true) - bot.Data = &BotData{ + bot.SetData(&BotData{ Name: "Jay", - } + }) - log.Printf("Authorized on account %s", bot.Api.Self.UserName) + log.Printf("Authorized on account %s", bot.Api().Self.UserName) err = bot.Run() if err != nil { panic(err) diff --git a/cmd/test/mutate.go b/cmd/test/mutate.go new file mode 100644 index 0000000..50afce3 --- /dev/null +++ b/cmd/test/mutate.go @@ -0,0 +1,100 @@ +package main + +import ( + "vultras.su/core/tg" + "strings" +) + +// The component to get incoming messages and +// send back mutated version. +type MutateMessageCompo struct { + Mutate func(string) string +} + +func NewMutateMessageCompo(fn func(string) string) *MutateMessageCompo { + ret := &MutateMessageCompo{} + ret.Mutate = fn + return ret +} + +func (w *MutateMessageCompo) Serve(c tg.Context) { + args, ok := c.Arg().([]any) + if ok { + for _, arg := range args { + c.Sendf("%v", arg) + } + } + for u := range c.Input() { + text := u.Message.Text + _, err := c.Sendf2("%s", w.Mutate(text)) + if err != nil { + c.Sendf("debug: %q", err) + } + } +} + +// Implementing the Filter interface making +// possible to give the updates away for +// the underlying components. +func (w *MutateMessageCompo) Filter(u tg.Update) bool { + if u.Message == nil { + return true + } + return false +} + +const ( + UpperCasePath tg.Path = "upper-case" + LowerCasePath = "lower-case" + EscapePath = "escape" + MutateMessagesPath = "mutate-messages" +) + +var MutateMessagesWidget = tg.RenderFunc(func(c tg.Context) tg.UI { + return tg.UI{ + tg.Messagef( + "Choose widget to mutate strings", + ).Reply( + tg.NewKeyboard().Row( + tg.Buttonf("Upper case").Go(UpperCasePath), + tg.Buttonf("Lower case").Go(LowerCasePath), + tg.Buttonf("Escape chars").Go(EscapePath), + ).Row( + BackButton, + ).Reply(), + ), + } +}) + +var MutateMessagesToLowerCaseWidget = tg.RenderFunc(func(c tg.Context) tg.UI { + return tg.UI{ + tg.Messagef( + "Type a string and the bot will convert it to lower case", + ).Reply( + BackKeyboard.Reply(), + ), + NewMutateMessageCompo(strings.ToLower), + } +}) + +var MutateMessagesToUpperCaseWidget = tg.RenderFunc(func(c tg.Context) tg.UI { + return tg.UI{ + tg.Messagef( + "Type a string and the bot will convert it to upper case", + ).Reply( + BackKeyboard.Reply(), + ), + NewMutateMessageCompo(strings.ToUpper), + } +}) +var MutateMessagesEscapeWidget = tg.RenderFunc(func(c tg.Context) tg.UI { + return tg.UI{ + tg.Messagef( + "Type a string and the bot will escape characters in it", + ).Reply( + BackKeyboard.Reply(), + ), + NewMutateMessageCompo(tg.Escape2), + } +}) + diff --git a/cmd/test/panel.go b/cmd/test/panel.go new file mode 100644 index 0000000..164ba02 --- /dev/null +++ b/cmd/test/panel.go @@ -0,0 +1,55 @@ +package main + +import ( + "vultras.su/core/tg" +) + +const ( + PanelPath tg.Path = "panel" +) + +var PanelWidget = tg.RenderFunc(func(c tg.Context) tg.UI { + var ( + n = 0 + ln = 4 + panel *tg.PanelCompo + ) + + panel = tg.Messagef( + "Some panel", + ).Panel(c, tg.RowserFunc(func(c tg.Context) []tg.ButtonRow { + btns := []tg.ButtonRow{ + tg.ButtonRow{tg.Buttonf("Page %d", n)}, + } + for i := 0; i < ln; i++ { + num := 1 + n*ln + i + btns = append(btns, tg.ButtonRow{ + tg.Buttonf("%d", num).Rand().WithAction(tg.Func(func(c tg.Context) { + _, err := c.Sendf("%d", num*num) + if err != nil { + } + })), + tg.Buttonf("%d", num*num), + }) + } + btns = append(btns, tg.ButtonRow{ + tg.Buttonf("Prev").WithAction(tg.Func(func(c tg.Context) { + n-- + panel.Update(c) + })), + tg.Buttonf("Next").WithAction(tg.Func(func(c tg.Context) { + n++ + panel.Update(c) + })), + }) + + return btns + })) + + return tg.UI{ + panel, + tg.Messagef("").Reply( + BackKeyboard.Reply(), + ), + } +}) diff --git a/cmd/test/start.go b/cmd/test/start.go new file mode 100644 index 0000000..9174914 --- /dev/null +++ b/cmd/test/start.go @@ -0,0 +1,49 @@ +package main + +import ( + "vultras.su/core/tg" + "fmt" +) + +const ( + StartAbsPath tg.Path = "/" +) +var StartWidget = tg.RenderFunc(func(c tg.Context) tg.UI { + return tg.UI{ + tg.Messagef( + fmt.Sprint( + "Hello, %s!\n", + "The testing bot started!\n", + "You can see the basics of usage in the ", + "cmd/test/main.go file!", + ), + c.CallbackUpdate().SentFrom().UserName, + ).Inline( + tg.NewKeyboard().Row( + tg.Buttonf("TeleGopher Vultras page"). + WithUrl("https://vultras.su/core/tg"), + ).Inline(), + ), + + tg.Messagef("Choose your interest").Reply( + tg.NewKeyboard().Row( + tg.Buttonf("Inc/Dec").Go(IncDecPath), + ).Row( + tg.Buttonf("Mutate messages").Go(MutateMessagesPath), + ).Row( + tg.Buttonf("Send location").Go(LocationPath), + ).Row( + tg.Buttonf("Dynamic panel").Go(PanelPath), + ).Reply(), + ), + + // Testing reaction to editing messages. + tg.Func(func(c tg.Context) { + for u := range c.Input() { + if u.EditedMessage != nil { + c.Sendf2("The new message is `%s`", u.EditedMessage.Text) + } + } + }), + } +}) diff --git a/command.go b/command.go index b1ea1c1..e1adbe4 100644 --- a/command.go +++ b/command.go @@ -18,21 +18,22 @@ type Command struct { Description string Action Action Widget Widget + WidgetArg any } -type CommandMap map[CommandName]*Command +type CommandMap map[CommandName]Command func NewCommand(name CommandName, desc string) Command { if name == "" || desc == "" { panic("name and description cannot be an empty string") } - return &Command{ + return Command{ Name: name, Description: desc, } } -func (c *Command) WithAction(a Action) *Command { +func (c Command) WithAction(a Action) Command { c.Action = a return c } @@ -42,10 +43,7 @@ func (c Command) WithWidget(w Widget) Command { return c } -func (c Command) WidgetFunc(fn Func) Command { - return c.WithWidget(fn) -} - +// Convert command into the tgbotapi.BotCommand func (c Command) ToApi() tgbotapi.BotCommand { ret := tgbotapi.BotCommand{} ret.Command = string(c.Name) @@ -53,10 +51,17 @@ func (c Command) ToApi() tgbotapi.BotCommand { return ret } -func (c Command) Go(pth Path, args ...any) Command { +// Simple command to go to another screen. +func (c Command) Go(pth Path) Command { return c.WithAction(ScreenGo{ Path: pth, - Args: args, + }) +} + +func (c Command) GoWithArg(pth Path, arg any) Command { + return c.WithAction(ScreenGo{ + Path: pth, + Arg: arg, }) } @@ -69,13 +74,13 @@ type CommandCompo struct { } // Returns new empty CommandCompo. -func NewCommandCompo(cmds ...*Command) *CommandCompo { - ret := CommandCompo{}.WithCommands(cmds...) +func NewCommandCompo(cmds ...Command) *CommandCompo { + ret := (&CommandCompo{}).SetCommands(cmds...) return ret } // Set the commands to handle. -func (w CommandCompo) WithCommands(cmds ...*Command) *CommandCompo { +func (w *CommandCompo) SetCommands(cmds ...Command) *CommandCompo { if w.Commands == nil { w.Commands = make(CommandMap) } @@ -93,24 +98,20 @@ func (w CommandCompo) WithCommands(cmds ...*Command) *CommandCompo { } // Set the prestart action. -func (w *CommandCompo) WithPreStart(a Action) *CommandCompo { +func (w *CommandCompo) SetPreStart(a Action) *CommandCompo { w.PreStart = a return w } // Set the usage action. -func (w CommandCompo) WithUsage(a Action) *CommandCompo { +func (w *CommandCompo) SetUsage(a Action) *CommandCompo { w.Usage = a return w } -// Set the usage action with function. -func (w CommandCompo) WithUsageFunc(fn ActionFunc) *CommandCompo { - return w.WithUsage(fn) -} - -func (widget CommandCompo) Filter( - u *Update, +// Filtering all the non commands. +func (widget *CommandCompo) Filter( + u Update, ) bool { if u.Message == nil || !u.Message.IsCommand() { return false @@ -120,16 +121,14 @@ func (widget CommandCompo) Filter( } // Implementing server. -func (compo CommandCompo) Serve(c Context) { - /*commanders := make(map[CommandName] BotCommander) - for k, v := range compo.Commands { - commanders[k] = v - }*/ - c.bot.DeleteCommands() - err := c.bot.SetCommands( - tgbotapi.NewBotCommandScopeChat(c.Session.Id.ToApi()), +func (compo *CommandCompo) Serve(c Context) { + // First should bring the new command into the action. + c.Bot().DeleteCommands() + err := c.Bot().SetCommands( + tgbotapi.NewBotCommandScopeChat(c.SessionId().ToApi()), compo.Commands, ) + if err != nil { c.Sendf("error: %q", err) } @@ -157,8 +156,10 @@ func (compo CommandCompo) Serve(c Context) { c.WithUpdate(u).Run(cmd.Action) if cmd.Widget != nil { + // Closing current widget cmdUpdates.Close() - cmdUpdates, _ = c.WithUpdate(u).RunWidget(cmd.Widget) + // And running the other one. + cmdUpdates, _ = c.runWidget(cmd.Widget, cmd.WidgetArg) } continue } @@ -168,7 +169,7 @@ func (compo CommandCompo) Serve(c Context) { // executing one. cmdUpdates.Send(u) } else { - c.Skip(u) + c.SkipUpdate(u) } } } diff --git a/context.go b/context.go index 62d7ebf..0e35b4c 100644 --- a/context.go +++ b/context.go @@ -11,6 +11,8 @@ import ( // Interface to interact with the user. type Context struct { + // The session contains all + // the information between the contexts. session *Session // The update that called the Context usage. update Update @@ -27,15 +29,15 @@ type Context struct { // make other user to leave the bot at first but // maybe you will find another usage for this. // Returns users context by specified session ID -// or nil if the user is not logged in. -func (c Context) As(sid SessionId) Context { - n, ok := c.Bot.contexts[sid] +// or false if the user is not logged in. +func (c Context) As(sid SessionId) (Context, bool) { + s, ok := c.Bot().GotSession(sid) if !ok { - return nil - } - return &Context{ - context: n, + return Context{}, false } + + c.session = s + return c, true } // General type function to define actions, single component widgets @@ -47,10 +49,10 @@ func (f Func) Act(c Context) { func (f Func) Serve(c Context) { f(c) } -func(f Func) Filter(_ *Update) bool { +func(f Func) Filter(_ Update) bool { return false } -func (f Func) Render(_ *Context) UI { +func (f Func) Render(_ Context) UI { return UI{ f, } @@ -65,19 +67,11 @@ const ( // Goroutie function to handle each user. func (c Context) serve() { - beh := c.Bot.behaviour + beh := c.Bot().behaviour c.Run(beh.Init) beh.Root.Serve(c) } -func (c Context) Path() Path { - ln := len(c.pathHistory) - if ln == 0 { - return "" - } - return c.pathHistory[ln-1] -} - func (c Context) Arg() any { return c.arg } @@ -88,21 +82,15 @@ func (c Context) Run(a Action) { } } -// Only for the root widget usage. -// Skip the update sending it down to -// the underlying widget. -func (c Context) Skip(u Update) { - c.skippedUpdates.Send(u) -} -// Sends to the Sendable object. -func (c Context) Send(v Sendable) (Message, error) { - config := v.SendConfig(c.Session.Id, c.Bot) +// Sends to the Sendable object to the session user. +func (c Context) Send(v Sendable) (*Message, error) { + config := v.SendConfig(c.SessionId(), c.Bot()) if config.Error != nil { return nil, config.Error } - msg, err := c.Bot.Api.Send(config.ToApi()) + msg, err := c.Bot().Api().Send(config.ToApi()) if err != nil { return nil, err } @@ -111,23 +99,23 @@ func (c Context) Send(v Sendable) (Message, error) { // Sends the formatted with fmt.Sprintf message to the user // using default Markdown parsing format. -func (c Context) Sendf(format string, v ...any) (Message, error) { - return c.Send(NewMessage(format, v...)) +func (c Context) Sendf(format string, v ...any) (*Message, error) { + return c.Send(Messagef(format, v...)) } // Same as Sendf but uses Markdown 2 format for parsing. -func (c Context) Sendf2(format string, v ...any) (Message, error) { - return c.Send(NewMessage(fmt.Sprintf(format, v...)).MD2()) +func (c Context) Sendf2(format string, v ...any) (*Message, error) { + return c.Send(Messagef(format, v...).MD2()) } // Same as Sendf but uses HTML format for parsing. -func (c Context) SendfHTML(format string, v ...any) (Message, error) { - return c.Send(NewMessage(fmt.Sprintf(format, v...)).HTML()) +func (c Context) SendfHTML(format string, v ...any) (*Message, error) { + return c.Send(Messagef(format, v...).HTML()) } // Send the message in raw format escaping all the special characters. -func (c Context) SendfR(format string, v ...any) (Message, error) { - return c.Send(NewMessage(Escape2(fmt.Sprintf(format, v...))).MD2()) +func (c Context) SendfR(format string, v ...any) (*Message, error) { + return c.Send(Messagef("%s", Escape2(fmt.Sprintf(format, v...))).MD2()) } // Get the input for current widget. @@ -141,8 +129,8 @@ func (c Context) WithArg(v any) Context { return c } -func (c Context) WithUpdate(u *Update) Context { - c.Update = u +func (c Context) WithUpdate(u Update) Context { + c.update = u return c } @@ -151,14 +139,6 @@ func (c Context) WithInput(input *UpdateChan) Context { return c } -func (c Context) Go(pth Path) error { - return c.session.go_(pth, nil) -} - -func (c Context) GoWithArg(pth Path, arg any) error { - return c.session.go_(pth, arg) -} - // Customized actions for the bot. type Action interface { Act(Context) @@ -166,29 +146,18 @@ type Action interface { type ActionFunc func(Context) -func (af ActionFunc) Act(c *Context) { +func (af ActionFunc) Act(c Context) { af(c) } -func (c Context) History() []Path { - return c.session.pathHistory -} - -func (c Context) PathExist(pth Path) bool { - return c.bot.behaviour.PathExist(pth) -} - // Simple way to read strings for widgets with // the specified prompt. func (c Context) ReadString(promptf string, args ...any) string { var text string - if pref != "" { + if promptf != "" { c.Sendf(promptf, args...) } for u := range c.Input() { - if u == nil { - break - } if u.Message == nil { continue } @@ -198,19 +167,19 @@ func (c Context) ReadString(promptf string, args ...any) string { return text } -func (c Context) Update() Update { - return c.update +func (c Context) CallbackUpdate() *Update { + return &c.update } // Returns the reader for specified file ID and path. -func (c *Context) GetFile(fileId FileId) (io.ReadCloser, string, error) { - file, err := c.Bot.Api.GetFile(tgbotapi.FileConfig{FileID:string(fileId)}) +func (c Context) GetFile(fileId FileId) (io.ReadCloser, string, error) { + file, err := c.Bot().Api().GetFile(tgbotapi.FileConfig{FileID:string(fileId)}) if err != nil { return nil, "", err } r, err := http.Get(fmt.Sprintf( "https://api.telegram.org/file/bot%s/%s", - c.Bot.Api.Token, + c.Bot().Api().Token, file.FilePath, )) if err != nil { @@ -223,7 +192,8 @@ func (c *Context) GetFile(fileId FileId) (io.ReadCloser, string, error) { return r.Body, file.FilePath, nil } -func (c *Context) ReadFile(fileId FileId) ([]byte, string, error) { +// Reads all the content from the specified file. +func (c Context) ReadFile(fileId FileId) ([]byte, string, error) { file, pth, err := c.GetFile(fileId) if err != nil { return nil, "", err @@ -238,3 +208,184 @@ func (c *Context) ReadFile(fileId FileId) ([]byte, string, error) { return bts, pth, nil } +func (c Context) runCompo(compo Component, arg any) (*UpdateChan, error) { + if compo == nil { + return nil, nil + } + sendable, canSend := compo.(Sendable) + if canSend { + msg, err := c.Send(sendable) + if err != nil { + return nil, err + } + sendable.SetMessage(msg) + } + updates := NewUpdateChan() + go func() { + compo.Serve( + c.WithInput(updates). + WithArg(arg), + ) + // To let widgets finish themselves before + // the channel is closed and close it by themselves. + updates.Close() + }() + return updates, nil +} + +// Run widget in background returning the new input channel for it. +func (c Context) runWidget(widget Widget, arg any) (*UpdateChan, error) { + var err error + if widget == nil { + return nil, EmptyWidgetErr + } + + pth := c.Path() + compos := widget.Render(c.WithArg(arg)) + // Leave if changed path or components are empty. + if compos == nil || pth != c.Path() { + return nil, EmptyCompoErr + } + chns := make([]*UpdateChan, len(compos)) + for i, compo := range compos { + chns[i], err = c.runCompo(compo, arg) + if err != nil { + for _, chn := range chns { + chn.Close() + } + return nil, err + } + } + + ret := NewUpdateChan() + go func() { + ln := len(compos) + //ation: u != nil (mismatchedtypes Update and untyped nil) + UPDATE: + for u := range ret.Chan() { + cnt := 0 + for i, compo := range compos { + chn := chns[i] + if chn.Closed() { + cnt++ + continue + } + if !compo.Filter(u) { + chn.Send(u) + continue UPDATE + } + } + if cnt == ln { + break + } + } + ret.Close() + for _, chn := range chns { + chn.Close() + } + }() + + return ret, nil +} + +// Simple go without an argument for context. +func (c Context) Go(pth Path) error { + return c.GoWithArg(pth, nil) +} + +// Changes screen of user to the Id one. +// Also gives the arg to the widget calling +// contexts. +func (c Context) GoWithArg(pth Path, arg any) error { + var err error + if pth == "" { + c.session.pathHistory = []Path{} + return nil + } + var back bool + if pth == "-" { + if len(c.session.pathHistory) < 2 { + return c.GoWithArg("", arg) + } + pth = c.session.pathHistory[len(c.session.pathHistory)-2] + c.session.pathHistory = c.session.pathHistory[:len(c.session.pathHistory)-1] + } + // Getting the screen and changing to + // then executing its widget. + if !pth.IsAbs() { + pth = (c.Path() + "/" + pth).Clean() + } + + if !c.PathExist(pth) { + return ScreenNotExistErr + } + + if !back && c.Path() != pth { + c.session.pathHistory = append(c.session.pathHistory, pth) + } + + // Stopping the current widget. + screen := c.Bot().behaviour.Screens[pth] + c.session.skippedUpdates.Close() + + if screen.Widget != nil { + c.session.skippedUpdates, err = c.runWidget(screen.Widget, arg) + if err != nil { + return err + } + } else { + return NoWidgetForScreenErr + } + + return nil +} + +func (c Context) Session() Session { + return *c.session +} + +func (c Context) SetSessionData(v any) { + c.session.Data = v +} + +func (c Context) SessionData() any { + return c.session.Data +} + +func (c Context) SessionId() SessionId { + return c.session.Id +} + +func (c Context) SessionScope() SessionScope { + return c.session.Scope +} + +// Only for the root widget usage. +// Skip the update sending it down to +// the underlying widget. +func (c Context) SkipUpdate(u Update) { + c.session.skippedUpdates.Send(u) +} + +// Return the session related bot. +func (c Context) Bot() *Bot { + return c.session.bot +} + +// Returns true if the path exists and false otherwise. +func (c Context) PathExist(pth Path) bool { + return c.session.bot.behaviour.PathExist(pth) +} + +// Return context's session's path history. +func (c Context) PathHistory() []Path { + return c.session.pathHistory +} + +func (c Context) Path() Path { + ln := len(c.session.pathHistory) + if ln == 0 { + return "" + } + return c.session.pathHistory[ln-1] +} diff --git a/file.go b/file.go index 86ff522..c198398 100644 --- a/file.go +++ b/file.go @@ -40,7 +40,7 @@ type File struct { func NewFile(reader io.Reader) *File { ret := &File{} - ret.MessageCompo = NewMessage("") + ret.MessageCompo = *Messagef("") ret.reader = reader ret.upload = true @@ -117,11 +117,11 @@ func (f *File) SendConfig( photo := tgbotapi.NewPhoto(cid, f) photo.Caption = f.caption - config.Photo = &photo + config.Chattable = photo case DocumentFileType: doc := tgbotapi.NewDocument(sid.ToApi(), f) doc.Caption = f.caption - config.Document = &doc + config.Chattable = doc default: panic(UnknownFileTypeErr) } diff --git a/filter.go b/filter.go index cafe364..d380577 100644 --- a/filter.go +++ b/filter.go @@ -7,12 +7,12 @@ package tg type Filterer interface { // Return true if should filter the update // and not send it inside the widget. - Filter(*Update) bool + Filter(Update) bool } -type FilterFunc func(*Update) bool +type FilterFunc func(Update) bool func (f FilterFunc) Filter( - u *Update, + u Update, ) bool { return f(u) } diff --git a/inline.go b/inline.go index dc1a700..5c11352 100644 --- a/inline.go +++ b/inline.go @@ -19,7 +19,7 @@ func (kbd Inline) ToApi() tgbotapi.InlineKeyboardMarkup { } buttons := []tgbotapi.InlineKeyboardButton{} for _, button := range row { - if button == nil { + if !button.Valid { continue } buttons = append(buttons, button.ToTelegramInline()) @@ -41,9 +41,11 @@ func (compo *InlineCompo) SendConfig( sid SessionId, bot *Bot, ) (SendConfig) { sendConfig := compo.MessageCompo.SendConfig(sid, bot) + msg := sendConfig.Chattable.(tgbotapi.MessageConfig) if len(compo.Inline.Rows) > 0 { - sendConfig.Message.ReplyMarkup = compo.Inline.ToApi() + msg.ReplyMarkup = compo.Inline.ToApi() } + sendConfig.Chattable = msg return sendConfig } @@ -51,34 +53,39 @@ func (compo *InlineCompo) SendConfig( // Update the component on the client side. // Requires exactly the pointer but not the value // cause it changes insides of the structure. -func (compo *InlineCompo) Update(c Context) { +func (compo *InlineCompo) Update(c Context) error { if compo.Message != nil { var edit tgbotapi.Chattable markup := compo.Inline.ToApi() ln := len(markup.InlineKeyboard) if ln == 0 || compo.Inline.Rows == nil { edit = tgbotapi.NewEditMessageText( - c.Session.Id.ToApi(), + c.SessionId().ToApi(), compo.Message.MessageID, compo.Text, ) } else { edit = tgbotapi.NewEditMessageTextAndMarkup( - c.Session.Id.ToApi(), + c.SessionId().ToApi(), compo.Message.MessageID, compo.Text, markup, ) } - msg, _ := c.Bot.Api.Send(edit) + msg, err := c.Bot().Api().Send(edit) + if err != nil { + return err + } compo.Message = &msg } - compo.buttonMap = compo.MakeButtonMap() + + + return nil } // Implementing the Filterer interface. -func (compo InlineCompo) Filter(u Update) bool { - if compo == nil || u.CallbackQuery == nil { +func (compo *InlineCompo) Filter(u Update) bool { + if u.CallbackQuery == nil { return true } @@ -91,13 +98,13 @@ func (compo InlineCompo) Filter(u Update) bool { } // Implementing the Server interface. -func (compo InlineCompo) Serve(c Context) { +func (compo *InlineCompo) Serve(c Context) { for u := range c.Input() { compo.OnOneUpdate(c, u) } } -func (compo *InlineCompo) OnOneUpdate(c Context, u Update) { +func (compo *InlineCompo) OnOneUpdate(c Context, u Update) error { var act Action btns := compo.ButtonMap() cb := tgbotapi.NewCallback( @@ -106,20 +113,23 @@ func (compo *InlineCompo) OnOneUpdate(c Context, u Update) { ) data := u.CallbackQuery.Data - _, err := c.Bot.Api.Request(cb) + _, err := c.Bot().Api().Request(cb) if err != nil { - return + return err } btn, ok := btns[data] if !ok { - return + return nil } - if btn != nil { + + if btn.Action != nil { act = btn.Action } else if compo.Action != nil { act = compo.Action } c.WithUpdate(u).Run(act) + + return nil } diff --git a/keyboard.go b/keyboard.go index 440cdc2..24a35c6 100644 --- a/keyboard.go +++ b/keyboard.go @@ -10,12 +10,11 @@ type Keyboard struct { // defined action for the button. Action Action Rows []ButtonRow - buttonMap ButtonMap } // Returns the new keyboard with specified rows. func NewKeyboard(rows ...ButtonRow) Keyboard { - ret := &Keyboard{} + ret := Keyboard{} for _, row := range rows { if row != nil && len(row) > 0 { ret.Rows = append(ret.Rows, row) @@ -41,9 +40,10 @@ func (kbd Keyboard) Row(btns ...Button) Keyboard { if len(btns) < 1 { return kbd } - retBtns := []*Button{} + + retBtns := make([]Button, 0, len(btns)) for _, btn := range btns { - if btn == nil { + if !btn.Valid { continue } retBtns = append(retBtns, btn) @@ -58,7 +58,7 @@ func (kbd Keyboard) Row(btns ...Button) Keyboard { // Adds buttons as one column list. func (kbd Keyboard) List(btns ...Button) Keyboard { for _, btn := range btns { - if btn == nil { + if !btn.Valid { continue } kbd.Rows = append(kbd.Rows, ButtonRow{btn}) @@ -76,10 +76,7 @@ func (kbd Keyboard) WithAction(a Action) Keyboard { // Returns the map of buttons. Where the key // is button data and the value is Action. func (kbd Keyboard) ButtonMap() ButtonMap { - if kbd.buttonMap == nil { - kbd.buttonMap = kbd.MakeButtonMap() - } - return kbd.buttonMap + return kbd.MakeButtonMap() } // Returns the map of buttons on the most fresh version of the keyboard. @@ -90,7 +87,6 @@ func (kbd Keyboard) MakeButtonMap() ButtonMap { ret[vj.Key()] = vj } } - kbd.buttonMap = ret return ret } @@ -103,8 +99,9 @@ func (kbd Keyboard) Inline() Inline { } // Convert the keyboard to the more specific reply one. +// By default OneTime = true. func (kbd Keyboard) Reply() Reply { - ret := &Reply{} + ret := Reply{} ret.Keyboard = kbd // it is used more often than not once. ret.OneTime = true diff --git a/location.go b/location.go index c01b5ea..9bca927 100644 --- a/location.go +++ b/location.go @@ -7,22 +7,22 @@ import ( type Location = tgbotapi.Location type LocationCompo struct { - *MessageCompo + MessageCompo Location } func (compo *LocationCompo) SendConfig( sid SessionId, bot *Bot, -) (*SendConfig) { +) (SendConfig) { cid := sid.ToApi() - loc := tgbotapi.NewLocation( + location := tgbotapi.NewLocation( cid, compo.Latitude, compo.Longitude, ) - ret := &SendConfig{ - Location: &loc, - } + + ret := SendConfig{} + ret.Chattable = location return ret } diff --git a/message.go b/message.go index a6fe070..d98ccc6 100644 --- a/message.go +++ b/message.go @@ -10,8 +10,13 @@ type Message = tgbotapi.Message // Simple text message component type. type MessageCompo struct { - Message Message + // Low level Message represents + // the already sent to the client message. + // Will be nil if the message is not rendered to the client. + Message *Message + // Parsing mode for the text: HTML, MD, MD2... ParseMode string + // The text to display. Text string } @@ -29,91 +34,93 @@ func Escape2(str string) string { // Call the function after the message was sent. func (compo *MessageCompo) Update(c Context) error { edit := tgbotapi.NewEditMessageText( - c.Session.Id.ToApi(), + c.Session().Id.ToApi(), compo.Message.MessageID, compo.Text, ) - msg, err := c.bot.api.Send(edit) + msg, err := c.Bot().Api().Send(edit) if err != nil { return err } - compo.Message = msg + compo.Message = &msg return nil } -func (compo *MessageCompo) Delete(c *Context) { - cfg := tgbotapi.NewDeleteMessage(c.Session.Id.ToApi(), compo.Message.MessageID) - c.Bot.Api.Send(cfg) - //c.Sendf("%q", err) +// Calling the method removes the message on the client side +// and sets the Message in the component to nil. +func (compo *MessageCompo) Delete(c Context) error { + cfg := tgbotapi.NewDeleteMessage(c.session.Id.ToApi(), compo.Message.MessageID) + _, err := c.Bot().Api().Send(cfg) + if err != nil { + return err + } + + // Empty the message if success. + compo.Message = nil + + return nil } -// Is only implemented to make it sendable and so we can put it -// return of rendering functions. -func (compo *MessageCompo) SetMessage(msg Message) { - compo.Message = msg -} - -// Return new message with the specified text. -func Messagef(format string, v ...any) MessageCompo { - ret := MessageCompo{} +// Return new message with the specified text +// formatted with the fmt.Sprintf function. +func Messagef(format string, v ...any) *MessageCompo { + ret := &MessageCompo{} ret.Text = fmt.Sprintf(format, v...) ret.ParseMode = tgbotapi.ModeMarkdown return ret } // Return message with the specified parse mode. -func (msg MessageCompo) withParseMode(mode string) MessageCompo { - msg.ParseMode = mode - return msg +func (compo *MessageCompo) setParseMode(mode string) *MessageCompo { + compo.ParseMode = mode + return compo } // Set the default Markdown parsing mode. -func (msg MessageCompo) MD() MessageCompo { - return msg.withParseMode(tgbotapi.ModeMarkdown) +func (compo *MessageCompo) MD() *MessageCompo { + return compo.setParseMode(tgbotapi.ModeMarkdown) } // Set the Markdown 2 parsing mode. -func (msg MessageCompo) MD2() MessageCompo { - return msg.withParseMode(tgbotapi.ModeMarkdownV2) +func (compo *MessageCompo) MD2() *MessageCompo { + return compo.setParseMode(tgbotapi.ModeMarkdownV2) } // Set the HTML parsing mode. -func (msg MessageCompo) HTML() MessageCompo { - return msg.withParseMode(tgbotapi.ModeHTML) +func (compo *MessageCompo) HTML() *MessageCompo { + return compo.setParseMode(tgbotapi.ModeHTML) } // Transform the message component into one with reply keyboard. -func (msg MessageCompo) Inline(inline Inline) InlineCompo { - return InlineCompo{ +func (compo *MessageCompo) Inline(inline Inline) *InlineCompo { + return &InlineCompo{ Inline: inline, - MessageCompo: msg, + MessageCompo: *compo, } } // Transform the message component into one with reply keyboard. -func (msg MessageCompo) Reply(reply Reply) ReplyCompo { - return ReplyCompo{ +func (msg *MessageCompo) Reply(reply Reply) *ReplyCompo { + return &ReplyCompo{ Reply: reply, - MessageCompo: msg, + MessageCompo: *msg, } } // Transform the message component into the location one. -func (msg MessageCompo) Location( +func (msg *MessageCompo) Location( lat, long float64, -) LocationCompo { - ret := &LocationCompo{ - MessageCompo: msg, - Location: Location{ - Latitude: lat, - Longitude: long, - }, - } +) *LocationCompo { + ret := &LocationCompo{} + ret.MessageCompo = *msg + ret.Latitude = lat + ret.Longitude = long + return ret } // Implementing the Sendable interface. -func (config MessageCompo) SendConfig( +func (compo *MessageCompo) SendConfig( sid SessionId, bot *Bot, ) (SendConfig) { var ( @@ -121,19 +128,27 @@ func (config MessageCompo) SendConfig( text string ) - if config.Text == "" { + // Protection against empty text, + // since it breaks the Telegram bot API. + if compo.Text == "" { text = ">" } else { - text = config.Text + text = compo.Text } msg := tgbotapi.NewMessage(sid.ToApi(), text) - msg.ParseMode = config.ParseMode + msg.ParseMode = compo.ParseMode ret.Chattable = msg return ret } +// Implementing the Sendable interface. +// Also used for embedding for things like InlineCompo etc. +func (compo *MessageCompo) SetMessage(msg *Message) { + compo.Message = msg +} + // Empty serving to use messages in rendering. func (compo *MessageCompo) Serve(c Context) {} diff --git a/panel.go b/panel.go index e2e4b55..2aaf678 100644 --- a/panel.go +++ b/panel.go @@ -1,11 +1,11 @@ package tg type Rowser interface { - MakeRows(c *Context) []ButtonRow + MakeRows(c Context) []ButtonRow } -type RowserFunc func(c *Context) []ButtonRow -func (fn RowserFunc) MakeRows(c *Context) []ButtonRow { +type RowserFunc func(c Context) []ButtonRow +func (fn RowserFunc) MakeRows(c Context) []ButtonRow { return fn(c) } @@ -14,26 +14,26 @@ func (fn RowserFunc) MakeRows(c *Context) []ButtonRow { // Can be used for example to show users via SQL and offset // or something like that. type PanelCompo struct { - *InlineCompo + InlineCompo Rowser Rowser } // Transform to the panel with dynamic rows. func (compo *MessageCompo) Panel( - c *Context, // The context that all the buttons will get. + c Context, // The context to generate the first page of buttons. rowser Rowser, // The rows generator. ) *PanelCompo { ret := &PanelCompo{} - ret.InlineCompo = compo.Inline( + ret.InlineCompo = (*compo.Inline( NewKeyboard( rowser.MakeRows(c)..., ).Inline(), - ) + )) ret.Rowser = rowser return ret } -func (compo *PanelCompo) Update(c *Context) { +func (compo *PanelCompo) Update(c Context) { compo.Rows = compo.Rowser.MakeRows(c) compo.InlineCompo.Update(c) } diff --git a/reply.go b/reply.go index 06a470a..1b42ec1 100644 --- a/reply.go +++ b/reply.go @@ -41,7 +41,7 @@ func (kbd Reply) ToApi() any { } buttons := []tgbotapi.KeyboardButton{} for _, button := range row { - if button == nil { + if !button.Valid { continue } buttons = append(buttons, button.ToTelegram()) @@ -63,17 +63,21 @@ type ReplyCompo struct { } // Implementing the sendable interface. -func (compo ReplyCompo) SendConfig( +func (compo *ReplyCompo) SendConfig( sid SessionId, bot *Bot, ) (SendConfig) { sendConfig := compo.MessageCompo.SendConfig(sid, bot) - sendConfig.Message.ReplyMarkup = compo.Reply.ToApi() + + msg := sendConfig.Chattable.(tgbotapi.MessageConfig) + msg.ReplyMarkup = compo.Reply.ToApi() + sendConfig.Chattable = msg + return sendConfig } // Implementing the Server interface. -func (compo ReplyCompo) Filter( - u *Update, +func (compo *ReplyCompo) Filter( + u Update, ) bool { if compo == nil || u.Message == nil { return true @@ -82,8 +86,8 @@ func (compo ReplyCompo) Filter( _, ok := compo.ButtonMap()[u.Message.Text] if !ok { if u.Message.Location != nil { - locBtn := compo.ButtonMap().LocationButton() - if locBtn == nil { + _, hasLocBtn := compo.ButtonMap().LocationButton() + if !hasLocBtn { return true } } else { @@ -94,22 +98,27 @@ func (compo ReplyCompo) Filter( } // Implementing the UI interface. -func (compo ReplyCompo) Serve(c *Context) { +func (compo *ReplyCompo) Serve(c Context) { for u := range c.Input() { - var btn *Button + var btn Button text := u.Message.Text btns := compo.ButtonMap() btn, ok := btns[text] if !ok { if u.Message.Location != nil { - btn = btns.LocationButton() + locBtn, hasLocBtn := btns.LocationButton() + if hasLocBtn { + btn = locBtn + } } } - if btn != nil { - c.WithUpdate(u).Run(btn.Action) + if !btn.Valid { + continue } + + c.WithUpdate(u).Run(btn.Action) } } diff --git a/screen.go b/screen.go index bafb3ba..674180a 100644 --- a/screen.go +++ b/screen.go @@ -7,15 +7,15 @@ import ( // The type implements changing screen to the underlying ScreenId type ScreenGo struct { Path Path - Args []any + Arg any } -func (sc ScreenGo) Act(c *Context) { - c.Go(sc.Path, sc.Args...) +func (sc ScreenGo) Act(c Context) { + c.GoWithArg(sc.Path, sc.Arg) } // The same as Act. -func (sc ScreenGo) Serve(c *Context) { +func (sc ScreenGo) Serve(c Context) { sc.Act(c) } diff --git a/send.go b/send.go index 48cdc5d..7403286 100644 --- a/send.go +++ b/send.go @@ -11,7 +11,7 @@ type MessageId int64 // sent to the side of a user. type Sendable interface { SendConfig(SessionId, *Bot) (SendConfig) - SetMessage(Message) + SetMessage(*Message) } // The type is used as an endpoint to send messages diff --git a/server.go b/server.go index 4d36063..ce782a7 100644 --- a/server.go +++ b/server.go @@ -3,6 +3,6 @@ package tg // Implementing the interface provides // the way to define how to handle updates. type Server interface { - Serve(*Context) + Serve(Context) } diff --git a/session.go b/session.go index 1c554f5..e8476fe 100644 --- a/session.go +++ b/session.go @@ -5,8 +5,12 @@ package tg type SessionMap map[SessionId]*Session // Add new empty session by it's ID. -func (sm SessionMap) Add(sid SessionId, scope SessionScope) *Session { - ret := NewSession(sid, scope) +func (sm SessionMap) Add( + bot *Bot, + sid SessionId, + scope SessionScope, +) *Session { + ret := NewSession(bot, sid, scope) sm[sid] = ret return ret } @@ -45,137 +49,15 @@ type Session struct { updates *UpdateChan } -// Return new empty session with specified user ID. -func NewSession(id SessionId, scope SessionScope) *Session { - return &Session{ - Id: id, - Scope: scope, - } +// Return new empty session. +func NewSession(bot *Bot, id SessionId, scope SessionScope) *Session { + ret := &Session{} + ret.Id = id + ret.Scope = scope + ret.bot = bot + ret.updates = NewUpdateChan() + ret.skippedUpdates = NewUpdateChan() + return ret } -// Changes screen of user to the Id one. -func (s *Session) go_(pth Path, arg any) error { - var err error - if pth == "" { - s.pathHistory = []Path{} - return nil - } - var back bool - if pth == "-" { - if len(s.pathHistory) < 2 { - return s.Go("") - } - pth = s.pathHistory[len(s.pathHistory)-2] - s.pathHistory = s.pathHistory[:len(s.pathHistory)-1] - } - // Getting the screen and changing to - // then executing its widget. - if !pth.IsAbs() { - pth = (s.Path() + "/" + pth).Clean() - } - if !s.PathExist(pth) { - return ScreenNotExistErr - } - - if !back && s.Path() != pth { - s.pathHistory = append(s.pathHistory, pth) - } - - // Stopping the current widget. - screen := s.bot.behaviour.Screens[pth] - s.skippedUpdates.Close() - - if screen.Widget != nil { - s.skippedUpdates, err = s.runWidget(screen.Widget, arg) - if err != nil { - return err - } - } else { - return NoWidgetForScreenErr - } - - return nil -} - -func (s *Session) runCompo(compo Component, arg any) (*UpdateChan, error) { - if compo == nil { - return nil, nil - } - s, ok := compo.(Sendable) - if ok { - msg, err := c.Send(s) - if err != nil { - return nil, err - } - s.SetMessage(msg) - } - updates := NewUpdateChan() - go func() { - compo.Serve( - c.WithInput(updates). - WithArg(arg), - ) - // To let widgets finish themselves before - // the channel is closed and close it by themselves. - updates.Close() - }() - return updates, nil -} - -// Run widget in background returning the new input channel for it. -func (c *Context) runWidget(widget Widget, arg any) (*UpdateChan, error) { - var err error - if widget == nil { - return nil, EmptyWidgetErr - } - - pth := c.Path() - compos := widget.Render(c.WithArg(c.makeArg(args))) - // Leave if changed path or components are empty. - if compos == nil || pth != c.Path() { - return nil, EmptyCompoErr - } - chns := make([]*UpdateChan, len(compos)) - for i, compo := range compos { - chns[i], err = c.runCompo(compo, arg) - if err != nil { - for _, chn := range chns { - chn.Close() - } - return nil, err - } - } - - ret := NewUpdateChan() - go func() { - ln := len(compos) - UPDATE: - for u := range ret.Chan() { - if u == nil { - break - } - cnt := 0 - for i, compo := range compos { - chn := chns[i] - if chn.Closed() { - cnt++ - continue - } - if !compo.Filter(u) { - chn.Send(u) - continue UPDATE - } - } - if cnt == ln { - break - } - } - ret.Close() - for _, chn := range chns { - chn.Close() - } - }() - - return ret, nil -} diff --git a/taskfile.yml b/taskfile.yml deleted file mode 100644 index a86fbd8..0000000 --- a/taskfile.yml +++ /dev/null @@ -1,6 +0,0 @@ -version: 3 - -tasks: - build: - cmds: - - go build -o testbot ./cmd/test/ diff --git a/ui.go b/ui.go index d952255..2a89ab4 100644 --- a/ui.go +++ b/ui.go @@ -3,12 +3,12 @@ package tg // The type describes dynamic screen widget // That can have multiple UI components. type Widget interface { - Render(*Context) UI + Render(Context) UI } // The way to describe custom function based Widgets. -type RenderFunc func(c *Context) UI -func (fn RenderFunc) Render(c *Context) UI { +type RenderFunc func(c Context) UI +func (fn RenderFunc) Render(c Context) UI { return fn(c) } diff --git a/update.go b/update.go index 219ddd2..cfd3122 100644 --- a/update.go +++ b/update.go @@ -16,18 +16,18 @@ type UpdateChan struct { // Return new update channel. func NewUpdateChan() *UpdateChan { ret := &UpdateChan{} - ret.chn = make(chan *Update) + ret.chn = make(chan Update) return ret } -func (updates *UpdateChan) Chan() chan *Update { +func (updates *UpdateChan) Chan() chan Update { return updates.chn } // Send an update to the channel. // Returns true if the update was sent. -func (updates *UpdateChan) Send(u *Update) bool { +func (updates *UpdateChan) Send(u Update) bool { defer recover() if updates == nil || updates.chn == nil { return false @@ -37,11 +37,11 @@ func (updates *UpdateChan) Send(u *Update) bool { } // Read an update from the channel. -func (updates *UpdateChan) Read() *Update { +func (updates *UpdateChan) Read() (Update, bool) { if updates == nil || updates.chn == nil { - return nil + return Update{}, false } - return <-updates.chn + return <-updates.chn, true } // Returns true if the channel is closed. @@ -60,8 +60,7 @@ func (updates *UpdateChan) Close() { } func (u Update) HasDocument() bool { - return u != nil && - u.Message != nil && + return u.Message != nil && u.Message.Document != nil }