Compare commits

..

11 commits
refact ... main

35 changed files with 1146 additions and 944 deletions

46
beh.go
View file

@ -1,65 +1,46 @@
package tg package tg
// The package implements
// behaviour for the Telegram bots.
// The type describes behaviour for the bot in personal chats. // The type describes behaviour for the bot in personal chats.
type Behaviour struct { type Behaviour struct {
Root Component Root Component
Init Action Init Action
Screens ScreenMap //Screens ScreenMap
} }
// Returns new empty behaviour. // Returns new empty behaviour.
func NewBehaviour() *Behaviour { func NewBehaviour() *Behaviour {
return &Behaviour{ return &Behaviour{
Screens: make(ScreenMap),
} }
} }
// The Action will be called on session creation, // The Action will be called on session creation,
// not when starting or restarting the bot with the Start Action. // 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 b.Init = a
return b return b
} }
// Alias to WithInit to simplify behaviour definitions. /*func (b *Behaviour) SetScreens(screens ScreenMap) *Behaviour {
func (b *Behaviour) WithInitFunc( b.Screens = screens
fn ActionFunc, return b
) *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() b.Screens = node.ScreenMap()
return b 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 // The function sets as the standard root widget CommandWidget
// and its commands.. // and its commands..
func (b *Behaviour) WithRoot(root Component) *Behaviour { func (b *Behaviour) SetRootWidget(root Component) *Behaviour {
b.Root = root b.Root = root
return b return b
} }
/*
// Check whether the screen exists in the behaviour. // Check whether the screen exists in the behaviour.
func (beh *Behaviour) PathExist(pth Path) bool { func (beh *Behaviour) PathExist(pth Path) bool {
_, ok := beh.Screens[pth] _, ok := beh.Screens[pth]
@ -68,7 +49,6 @@ func (beh *Behaviour) PathExist(pth Path) bool {
// Returns the screen by it's ID. // Returns the screen by it's ID.
func (beh *Behaviour) GetScreen(pth Path) *Screen { func (beh *Behaviour) GetScreen(pth Path) *Screen {
pth = pth.Clean()
if !beh.PathExist(pth) { if !beh.PathExist(pth) {
panic(ScreenNotExistErr) panic(ScreenNotExistErr)
} }
@ -76,4 +56,4 @@ func (beh *Behaviour) GetScreen(pth Path) *Screen {
screen := beh.Screens[pth] screen := beh.Screens[pth]
return screen return screen
} }
*/

126
bot.go
View file

@ -38,12 +38,12 @@ func NewBot(token string) (*Bot, error) {
}, nil }, nil
} }
func (bot *Bot) Debug(debug bool) *Bot { func (bot *Bot) SetDebug(debug bool) *Bot {
bot.api.Debug = debug bot.api.Debug = debug
return bot return bot
} }
func (bot *Bot) Api() *tgbotapi.BotAPI { func (bot *Bot) API() *tgbotapi.BotAPI {
return bot.api return bot.api
} }
@ -53,36 +53,35 @@ func (bot *Bot) Me() User {
// Send the Renderable to the specified session client side. // Send the Renderable to the specified session client side.
// Can be used for both group and private sessions because // Can be used for both group and private sessions because
// SessionId represents both for chat IDs. // SessionID represents both for chat IDs.
func (bot *Bot) Send( func (bot *Bot) Send(
sid SessionId, v Sendable, sid SessionID, v Sendable,
) (Message, error) { ) (*Message, error) {
config := v.SendConfig(sid, bot) config := v.SendConfig(sid, bot)
if config.Error != nil { if config.Error != nil {
return Message{}, config.Error return nil, config.Error
} }
msg, err := bot.api.Send(config.ToApi()) msg, err := bot.api.Send(config.ToAPI())
if err != nil { if err != nil {
return Message{}, err return nil, err
} }
v.SetMessage(msg) v.SetMessage(&msg)
return msg, nil return &msg, nil
} }
func (bot *Bot) Sendf( func (bot *Bot) Sendf(
sid SessionId, format string, v ...any, sid SessionID, format string, v ...any,
) (Message, error){ ) (*Message, error){
msg := Messagef(format, v...)
return bot.Send( return bot.Send(
sid, sid,
&msg, Messagef(format, v...),
) )
} }
// Send to the session specified its ID raw chattable from the tgbotapi. // Send to the session specified its ID raw chattable from the tgbotapi.
func (bot *Bot) SendRaw( func (bot *Bot) SendRaw(
sid SessionId, v tgbotapi.Chattable, sid SessionID, v tgbotapi.Chattable,
) (*Message, error) { ) (*Message, error) {
msg, err := bot.api.Send(v) msg, err := bot.api.Send(v)
if err != nil { if err != nil {
@ -93,20 +92,29 @@ func (bot *Bot) SendRaw(
// Get session by its ID. Can be used for any scope // Get session by its ID. Can be used for any scope
// including private, group and channel. // including private, group and channel.
func (bot *Bot) GetSession( func (bot *Bot) GotSession(
sid SessionId, sid SessionID,
) (*Session, bool) { ) (*Session, bool) {
session, ok := bot.sessions[sid] session, ok := bot.sessions[sid]
return session, ok 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.behaviour = beh
b.sessions = make(SessionMap) b.sessions = make(SessionMap)
return b return b
} }
func (b *Bot) WithSessions(sessions SessionMap) *Bot { func (b *Bot) SetSessions(sessions SessionMap) *Bot {
b.sessions = sessions b.sessions = sessions
return b return b
} }
@ -140,7 +148,7 @@ func (bot *Bot) SetCommands(
} }
sort.Strings(names) sort.Strings(names)
cmds := []*Command{} cmds := []Command{}
for _, name := range names { for _, name := range names {
cmds = append( cmds = append(
cmds, cmds,
@ -150,7 +158,7 @@ func (bot *Bot) SetCommands(
botCmds := []tgbotapi.BotCommand{} botCmds := []tgbotapi.BotCommand{}
for _, cmd := range cmds { for _, cmd := range cmds {
botCmds = append(botCmds, cmd.ToApi()) botCmds = append(botCmds, cmd.ToAPI())
} }
//tgbotapi.NewBotCommandScopeAllPrivateChats(), //tgbotapi.NewBotCommandScopeAllPrivateChats(),
@ -202,7 +210,7 @@ func (bot *Bot) Run() error {
go bot.handleGroup(chn) go bot.handleGroup(chn)
}*/ }*/
me, _ := bot.Api.GetMe() me, _ := bot.API().GetMe()
bot.me = me bot.me = me
for up := range updates { for up := range updates {
u := Update{ u := Update{
@ -216,6 +224,7 @@ func (bot *Bot) Run() error {
} }
chn, ok := handles[fromChat.Type] chn, ok := handles[fromChat.Type]
// Skipping non existent scope.
if !ok { if !ok {
continue continue
} }
@ -229,67 +238,32 @@ func (bot *Bot) Run() error {
// The function handles updates supposed for the private // The function handles updates supposed for the private
// chat with the bot. // chat with the bot.
func (bot *Bot) handlePrivate(updates chan Update) { func (bot *Bot) handlePrivate(updates chan Update) {
var sid SessionId var sid SessionID
for u := range updates { for u := range updates {
sid = SessionId(u.FromChat().ID) sid = SessionID(u.FromChat().ID)
ctx, ctxOk := bot.contexts[sid] session, sessionOk := bot.sessions[sid]
if u.Message != nil && !ctxOk { if u.Message != nil && !sessionOk {
// Creating session if we have none
// but only on text messages.
session = bot.sessions.Add(bot, sid, PrivateSessionScope)
session, sessionOk := bot.sessions[sid] // Creating the root context
if !sessionOk { // that takes updates directly from
// Creating session if we have none. // the session.
session = bot.sessions.Add(sid, PrivateSessionScope) rootContext := Context{
}
session = bot.sessions[sid]
// 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{
session: session, session: session,
bot: bot, update: u,
Update: u, input: session.updates,
input: ctx.updates, }
}.serve() go rootContext.serve()
ctx.session.updates.Send(u) rootContext.input.Send(u)
continue continue
} }
if ctxOk { if sessionOk {
ctx.updates.Send(u) 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
}
}
*/

3
btest Executable file
View file

@ -0,0 +1,3 @@
#!/bin/sh
#
go build -o ./exe/test ./cmd/test

View file

@ -11,34 +11,40 @@ import (
type Button struct { type Button struct {
Text string Text string
Data string Data string
Url string URL string
SendLocation bool SendLocation bool
Action Action Action Action
// Used to skip buttons in generating by functions.
Valid bool
} }
type ButtonMap map[string]Button type ButtonMap map[string]Button
// Returns the only location button in the map. // Returns the only location button in the map and if it is there at all.
func (btnMap ButtonMap) LocationButton() *Button { // The location map must be the ONLY one.
func (btnMap ButtonMap) LocationButton() (Button, bool) {
for _, btn := range btnMap { for _, btn := range btnMap {
if btn.SendLocation { if btn.SendLocation {
return btn return btn, true
} }
} }
return nil return Button{}, false
} }
// Represents the reply button row. // Represents the reply button row.
type ButtonRow []*Button type ButtonRow []Button
// Returns new button with the specified text and no action. // Returns new button with the specified text and no action.
func Buttonf(format string, v ...any) Button { func Buttonf(format string, v ...any) Button {
return &Button{ return Button{
Text: fmt.Sprintf(format, v...), Text: fmt.Sprintf(format, v...),
Valid: true,
} }
} }
// Randomize buttons data to make the key unique. // Randomize buttons data to make the key unique.
// No guaranties about collisions though.
func (btn Button) Rand() Button { func (btn Button) Rand() Button {
rData := make([]byte, 8) rData := make([]byte, 8)
rand.Read(rData) rand.Read(rData)
@ -49,8 +55,8 @@ func (btn Button) Rand() Button {
} }
// Set the URL for the button. Only for inline buttons. // Set the URL for the button. Only for inline buttons.
func (btn Button) WithUrl(format string, v ...any) Button { func (btn Button) WithURL(format string, v ...any) Button {
btn.Url = fmt.Sprintf(format, v...) btn.URL = fmt.Sprintf(format, v...)
return btn return btn
} }
@ -72,10 +78,16 @@ func (btn Button) WithSendLocation(ok bool) Button {
return btn return btn
} }
func (btn Button) Go(pth Path, args ...any) Button { func (btn Button) Go(pth Widget) Button {
return btn.WithAction(ScreenGo{ return btn.WithAction(WidgetGo{
Path: pth, Path: pth,
Args: args, })
}
func (btn Button) GoWithArg(pth Widget, arg any) Button {
return btn.WithAction(WidgetGo{
Path: pth,
Arg: arg,
}) })
} }
@ -89,11 +101,17 @@ func (btn Button) ToTelegram() apix.KeyboardButton {
func (btn Button) ToTelegramInline() apix.InlineKeyboardButton { func (btn Button) ToTelegramInline() apix.InlineKeyboardButton {
if btn.Data != "" { if btn.Data != "" {
return apix.NewInlineKeyboardButtonData(btn.Text, btn.Data) return apix.NewInlineKeyboardButtonData(
btn.Text,
btn.Data,
)
} }
if btn.Url != "" { 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. // 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. // Return the key of the button to identify it by messages and callbacks.
func (btn Button) Key() string { func (btn Button) Key() string {
if btn == nil {
return ""
}
if btn.Data != "" { if btn.Data != "" {
return btn.Data return btn.Data
} }
@ -113,6 +128,7 @@ func (btn Button) Key() string {
return btn.Text return btn.Text
} }
func NewButtonRow(btns ...*Button) ButtonRow { func NewButtonRow(btns ...Button) ButtonRow {
return btns return btns
} }

86
cmd/test/cmd.go Normal file
View file

@ -0,0 +1,86 @@
package main
import (
"surdeus.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(StartWidget),
tg.NewCommand(
"info",
"info desc",
).WithAction(tg.Func(func(c tg.Context) {
c.SendfHTML(`<a href="https://res.cloudinary.com/demo/image/upload/v1312461204/sample.jpg">cock</a><strong>cock</strong> 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,
),
)
})),
}

57
cmd/test/incdec.go Normal file
View file

@ -0,0 +1,57 @@
package main
import (
"surdeus.su/core/tg"
"fmt"
)
// A simple example widget to show
// how to store and get session data values
// and working with dynamic panels.
var IncDecWidget = tg.RenderFunc(func(c tg.Context) tg.UI {
const format = "Press the buttons" +
"to increment and decrement.\n" +
"Current counter value = %d"
d := ExtractSessionData(c)
return tg.UI{
tg.Messagef(format, d.Counter).Panel(
c,
tg.PanelFunc(func(
panel *tg.PanelCompo,
c tg.Context,
) []tg.ButtonRow {
d := ExtractSessionData(c)
row := tg.ButtonRow{}
if d.Counter != -5 {
row = append(
row,
tg.Buttonf(
"-",
).WithAction(tg.Func(func(c tg.Context){
d.Counter--
panel.Text = fmt.Sprintf(format, d.Counter)
c.Update(panel)
})),
)
}
if d.Counter != +5 {
row = append(
row,
tg.Buttonf(
"+",
).WithAction(tg.Func(func(c tg.Context){
d.Counter++
panel.Text = fmt.Sprintf(format, d.Counter)
c.Update(panel)
})),
)
}
return []tg.ButtonRow{row}
}),
),
tg.Messagef("Use the reply keyboard to get back").Reply(
BackKeyboard.Reply(),
),
}
})

30
cmd/test/keyboard.go Normal file
View file

@ -0,0 +1,30 @@
package main
import (
"surdeus.su/core/tg"
)
var HomeButton = tg.Buttonf("Home").Go(StartWidget)
var BackButton = tg.Buttonf("Back").Go(tg.Back)
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()

30
cmd/test/location.go Normal file
View file

@ -0,0 +1,30 @@
package main
import (
"surdeus.su/core/tg"
)
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,
),
}
})

View file

@ -3,10 +3,8 @@ package main
import ( import (
"log" "log"
"os" "os"
"strings"
"fmt"
"vultras.su/core/tg" "surdeus.su/core/tg"
) )
type BotData struct { type BotData struct {
@ -17,393 +15,47 @@ type SessionData struct {
Counter int Counter int
} }
type MutateMessageWidget struct { func ExtractSessionData(c tg.Context) *SessionData {
Mutate func(string) string return c.SessionData().(*SessionData)
} }
func NewMutateMessageWidget(fn func(string) string) *MutateMessageWidget { var BackWidget = tg.RenderFunc(func(c tg.Context) tg.UI{
ret := &MutateMessageWidget{} return c.GoRet(tg.Back)
ret.Mutate = fn })
return ret
}
func (w *MutateMessageWidget) Serve(c *tg.Context) { var beh = tg.NewBehaviour().SetInit(tg.Func(func(c tg.Context) {
args, ok := c.Arg().([]any) // The session initialization.
if ok { c.SetSessionData(&SessionData{})
for _, arg := range args { })).SetRootWidget(
c.Sendf("%v", arg) // Setting as the most top
} // widget command handling
} // so we can call them at any screen.
for u := range c.Input() { tg.NewCommandCompo().SetUsage(
text := u.Message.Text UsageAction,
_, err := c.Sendf2("%s", w.Mutate(text)) ).SetPreStart(
if err != nil { PreStartAction,
c.Sendf("debug: %q", err) ).SetCommands(
} BotCommands...,
} ),
}
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(
// 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)
}
}
}),
}
}),
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(),
),
}
}),
),
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(),
),
}
}),
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),
}
}),
),
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),
}
}),
),
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),
}
}),
),
),
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(),
),
}
}),
),
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,
),
}
}),
),
)).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(`<a href="https://res.cloudinary.com/demo/image/upload/v1312461204/sample.jpg">cock</a><strong>cock</strong> 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) {
})),
))
func main() { func main() {
log.Println(beh.Screens)
token := os.Getenv("BOT_TOKEN") token := os.Getenv("BOT_TOKEN")
bot, err := tg.NewBot(token) bot, err := tg.NewBot(token)
if err != nil { if err != nil {
log.Panic(err) log.Fatalf("tg.NewBot(...): %s", err)
} }
bot = bot. bot = bot.SetBehaviour(beh)
WithBehaviour(beh). //bot.API().Debug = true
Debug(true)
bot.Data = &BotData{ bot.SetData(&BotData{
Name: "Jay", 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() err = bot.Run()
if err != nil { if err != nil {
panic(err) log.Fatalf("bot.Run(...): %s", err)
} }
} }

92
cmd/test/mutate.go Normal file
View file

@ -0,0 +1,92 @@
package main
import (
"surdeus.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
}
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(UpperCaseWidget),
tg.Buttonf("Lower case").Go(LowerCaseWidget),
tg.Buttonf("Escape chars").Go(EscapeWidget),
).Row(
BackButton,
).Reply(),
),
}
})
var LowerCaseWidget = 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 UpperCaseWidget = 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 EscapeWidget = 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),
}
})

38
cmd/test/panel.go Normal file
View file

@ -0,0 +1,38 @@
package main
import (
"surdeus.su/core/tg"
)
var DynamicPanelWidget = tg.RenderFunc(func(c tg.Context) tg.UI {
return tg.UI{
tg.Messagef("Paged panel").PanelPager(
c, 0, 5,
tg.PanelPagerFunc(func(
panel *tg.PanelPagerCompo,
c tg.Context, page, size int,
) tg.PanelPage {
rows := []tg.ButtonRow{}
for i := 0; i < size; i++ {
num := 1 + page*size + i
rows = append(rows, 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),
})
}
return tg.PanelPage{
Rows: rows,
Next: page < 3,
Prev: page != 0,
}
}),
),
tg.Messagef("").Reply(
BackKeyboard.Reply(),
),
}
})

45
cmd/test/start.go Normal file
View file

@ -0,0 +1,45 @@
package main
import (
"surdeus.su/core/tg"
"fmt"
)
var StartWidget = tg.RenderFunc(func(c tg.Context) tg.UI {
return tg.UI{
tg.Messagef(
fmt.Sprint(
"Hello, %s!",
"The testing bot started!",
"You can see the basics of usage in the ",
"cmd/test/main.go file and other files in the cmd/test!",
),
c.CallbackUpdate().SentFrom().UserName,
).Inline(
tg.NewKeyboard().Row(
tg.Buttonf("TeleGopher surdeus.su page").
WithURL("https://surdeus.su/core/tg"),
).Inline(),
),
tg.Messagef("Choose your interest").Reply(
tg.NewKeyboard().List(
tg.Buttonf("Back").Go(BackWidget),
tg.Buttonf("Inc/Dec").Go(IncDecWidget),
tg.Buttonf("Mutate messages").Go(MutateMessagesWidget),
tg.Buttonf("Send location").Go(LocationWidget),
tg.Buttonf("Dynamic panel").Go(DynamicPanelWidget),
tg.Buttonf("Check panic").Go(nil),
).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)
}
}
}),
}
})

View file

@ -4,6 +4,7 @@ import (
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
) )
type CommandType uint8 type CommandType uint8
const ( const (
PrivateCommandType CommandType = iota PrivateCommandType CommandType = iota
@ -18,21 +19,22 @@ type Command struct {
Description string Description string
Action Action Action Action
Widget Widget Widget Widget
WidgetArg any
} }
type CommandMap map[CommandName]*Command type CommandMap map[CommandName]Command
func NewCommand(name CommandName, desc string) Command { func NewCommand(name CommandName, desc string) Command {
if name == "" || desc == "" { if name == "" || desc == "" {
panic("name and description cannot be an empty string") panic("name and description cannot be an empty string")
} }
return &Command{ return Command{
Name: name, Name: name,
Description: desc, Description: desc,
} }
} }
func (c *Command) WithAction(a Action) *Command { func (c Command) WithAction(a Action) Command {
c.Action = a c.Action = a
return c return c
} }
@ -42,21 +44,25 @@ func (c Command) WithWidget(w Widget) Command {
return c return c
} }
func (c Command) WidgetFunc(fn Func) Command { // Convert command into the tgbotapi.BotCommand
return c.WithWidget(fn) func (c Command) ToAPI() tgbotapi.BotCommand {
}
func (c Command) ToApi() tgbotapi.BotCommand {
ret := tgbotapi.BotCommand{} ret := tgbotapi.BotCommand{}
ret.Command = string(c.Name) ret.Command = string(c.Name)
ret.Description = c.Description ret.Description = c.Description
return ret return ret
} }
func (c Command) Go(pth Path, args ...any) Command { // Simple command to go to another screen.
return c.WithAction(ScreenGo{ func (c Command) Go(pth Widget) Command {
return c.WithAction(WidgetGo{
Path: pth, Path: pth,
Args: args, })
}
func (c Command) GoWithArg(pth Widget, arg any) Command {
return c.WithAction(WidgetGo{
Path: pth,
Arg: arg,
}) })
} }
@ -69,13 +75,13 @@ type CommandCompo struct {
} }
// Returns new empty CommandCompo. // Returns new empty CommandCompo.
func NewCommandCompo(cmds ...*Command) *CommandCompo { func NewCommandCompo(cmds ...Command) *CommandCompo {
ret := CommandCompo{}.WithCommands(cmds...) ret := (&CommandCompo{}).SetCommands(cmds...)
return ret return ret
} }
// Set the commands to handle. // Set the commands to handle.
func (w CommandCompo) WithCommands(cmds ...*Command) *CommandCompo { func (w *CommandCompo) SetCommands(cmds ...Command) *CommandCompo {
if w.Commands == nil { if w.Commands == nil {
w.Commands = make(CommandMap) w.Commands = make(CommandMap)
} }
@ -93,24 +99,20 @@ func (w CommandCompo) WithCommands(cmds ...*Command) *CommandCompo {
} }
// Set the prestart action. // Set the prestart action.
func (w *CommandCompo) WithPreStart(a Action) *CommandCompo { func (w *CommandCompo) SetPreStart(a Action) *CommandCompo {
w.PreStart = a w.PreStart = a
return w return w
} }
// Set the usage action. // Set the usage action.
func (w CommandCompo) WithUsage(a Action) *CommandCompo { func (w *CommandCompo) SetUsage(a Action) *CommandCompo {
w.Usage = a w.Usage = a
return w return w
} }
// Set the usage action with function. // Filtering all the non commands.
func (w CommandCompo) WithUsageFunc(fn ActionFunc) *CommandCompo { func (widget *CommandCompo) Filter(
return w.WithUsage(fn) u Update,
}
func (widget CommandCompo) Filter(
u *Update,
) bool { ) bool {
if u.Message == nil || !u.Message.IsCommand() { if u.Message == nil || !u.Message.IsCommand() {
return false return false
@ -120,27 +122,26 @@ func (widget CommandCompo) Filter(
} }
// Implementing server. // Implementing server.
func (compo CommandCompo) Serve(c Context) { func (compo *CommandCompo) Serve(c Context) {
/*commanders := make(map[CommandName] BotCommander) // First should bring the new command into the action.
for k, v := range compo.Commands { c.Bot().DeleteCommands()
commanders[k] = v err := c.Bot().SetCommands(
}*/ tgbotapi.NewBotCommandScopeChat(c.SessionID().ToAPI()),
c.bot.DeleteCommands()
err := c.bot.SetCommands(
tgbotapi.NewBotCommandScopeChat(c.Session.Id.ToApi()),
compo.Commands, compo.Commands,
) )
if err != nil { if err != nil {
c.Sendf("error: %q", err) c.Sendf("error: %q", err)
} }
var cmdUpdates *UpdateChan var cmdUpdates *UpdateChan
for u := range c.Input() { for u := range c.Input() {
if c.Path() == "" && u.Message != nil { if c.Path() == nil && u.Message != nil {
// Skipping and executing the preinit action // Skipping and executing the preinit action
// while we have the empty screen. // while we have the empty screen.
// E. g. the session did not start. // E. g. the session did not start.
if !(u.Message.IsCommand() && u.Message.Command() == "start") { if !u.Message.IsCommand() ||
u.Message.Command() != "start" {
c.WithUpdate(u).Run(compo.PreStart) c.WithUpdate(u).Run(compo.PreStart)
continue continue
} }
@ -157,8 +158,10 @@ func (compo CommandCompo) Serve(c Context) {
c.WithUpdate(u).Run(cmd.Action) c.WithUpdate(u).Run(cmd.Action)
if cmd.Widget != nil { if cmd.Widget != nil {
// Closing current widget
cmdUpdates.Close() cmdUpdates.Close()
cmdUpdates, _ = c.WithUpdate(u).RunWidget(cmd.Widget) // And running the other one.
cmdUpdates, _ = c.WithArg(cmd.WidgetArg).RunWidget(cmd.Widget)
} }
continue continue
} }
@ -168,7 +171,7 @@ func (compo CommandCompo) Serve(c Context) {
// executing one. // executing one.
cmdUpdates.Send(u) cmdUpdates.Send(u)
} else { } else {
c.Skip(u) c.SkipUpdate(u)
} }
} }
} }

View file

@ -6,11 +6,14 @@ import (
"net/http" "net/http"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
//"path" //"path"
//"log"
) )
// Interface to interact with the user. // Interface to interact with the user.
type Context struct { type Context struct {
// The session contains all
// the information between the contexts.
session *Session session *Session
// The update that called the Context usage. // The update that called the Context usage.
update Update update Update
@ -27,15 +30,15 @@ type Context struct {
// make other user to leave the bot at first but // make other user to leave the bot at first but
// maybe you will find another usage for this. // maybe you will find another usage for this.
// Returns users context by specified session ID // Returns users context by specified session ID
// or nil if the user is not logged in. // or false if the user is not logged in.
func (c Context) As(sid SessionId) Context { func (c Context) As(sid SessionID) (Context, bool) {
n, ok := c.Bot.contexts[sid] s, ok := c.Bot().GotSession(sid)
if !ok { if !ok {
return nil return Context{}, false
}
return &Context{
context: n,
} }
c.session = s
return c, true
} }
// General type function to define actions, single component widgets // General type function to define actions, single component widgets
@ -47,15 +50,18 @@ func (f Func) Act(c Context) {
func (f Func) Serve(c Context) { func (f Func) Serve(c Context) {
f(c) f(c)
} }
func(f Func) Filter(_ *Update) bool { func(f Func) Filter(_ Update) bool {
return false return false
} }
func (f Func) Render(_ *Context) UI { func (f Func) Render(_ Context) UI {
return UI{ return UI{
f, f,
} }
} }
// The type represents type
// of current context the processing is happening
// in.
type ContextType uint8 type ContextType uint8
const ( const (
NoContextType ContextType = iota NoContextType ContextType = iota
@ -65,17 +71,16 @@ const (
// Goroutie function to handle each user. // Goroutie function to handle each user.
func (c Context) serve() { func (c Context) serve() {
beh := c.Bot.behaviour beh := c.Bot().behaviour
c.Run(beh.Init) c.Run(beh.Init)
beh.Root.Serve(c) for {
} defer func(){
if err := recover() ; err != nil {
func (c Context) Path() Path { // Need to add some handling later.
ln := len(c.pathHistory) }
if ln == 0 { }()
return "" beh.Root.Serve(c)
} }
return c.pathHistory[ln-1]
} }
func (c Context) Arg() any { func (c Context) Arg() any {
@ -88,21 +93,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. // Sends to the Sendable object to the session user.
func (c Context) Send(v Sendable) (Message, error) { func (c Context) Send(v Sendable) (*Message, error) {
config := v.SendConfig(c.Session.Id, c.Bot) config := v.SendConfig(c.SessionID(), c.Bot())
if config.Error != nil { if config.Error != nil {
return nil, config.Error return nil, config.Error
} }
msg, err := c.Bot.Api.Send(config.ToApi()) msg, err := c.Bot().API().Send(config.ToAPI())
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -111,23 +110,23 @@ func (c Context) Send(v Sendable) (Message, error) {
// Sends the formatted with fmt.Sprintf message to the user // Sends the formatted with fmt.Sprintf message to the user
// using default Markdown parsing format. // using default Markdown parsing format.
func (c Context) Sendf(format string, v ...any) (Message, error) { func (c Context) Sendf(format string, v ...any) (*Message, error) {
return c.Send(NewMessage(format, v...)) return c.Send(Messagef(format, v...))
} }
// Same as Sendf but uses Markdown 2 format for parsing. // Same as Sendf but uses Markdown 2 format for parsing.
func (c Context) Sendf2(format string, v ...any) (Message, error) { func (c Context) Sendf2(format string, v ...any) (*Message, error) {
return c.Send(NewMessage(fmt.Sprintf(format, v...)).MD2()) return c.Send(Messagef(format, v...).MD2())
} }
// Same as Sendf but uses HTML format for parsing. // Same as Sendf but uses HTML format for parsing.
func (c Context) SendfHTML(format string, v ...any) (Message, error) { func (c Context) SendfHTML(format string, v ...any) (*Message, error) {
return c.Send(NewMessage(fmt.Sprintf(format, v...)).HTML()) return c.Send(Messagef(format, v...).HTML())
} }
// Send the message in raw format escaping all the special characters. // Send the message in raw format escaping all the special characters.
func (c Context) SendfR(format string, v ...any) (Message, error) { func (c Context) SendfR(format string, v ...any) (*Message, error) {
return c.Send(NewMessage(Escape2(fmt.Sprintf(format, v...))).MD2()) return c.Send(Messagef("%s", Escape2(fmt.Sprintf(format, v...))).MD2())
} }
// Get the input for current widget. // Get the input for current widget.
@ -141,8 +140,8 @@ func (c Context) WithArg(v any) Context {
return c return c
} }
func (c Context) WithUpdate(u *Update) Context { func (c Context) WithUpdate(u Update) Context {
c.Update = u c.update = u
return c return c
} }
@ -151,14 +150,6 @@ func (c Context) WithInput(input *UpdateChan) Context {
return c 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. // Customized actions for the bot.
type Action interface { type Action interface {
Act(Context) Act(Context)
@ -166,29 +157,18 @@ type Action interface {
type ActionFunc func(Context) type ActionFunc func(Context)
func (af ActionFunc) Act(c *Context) { func (af ActionFunc) Act(c Context) {
af(c) 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 // Simple way to read strings for widgets with
// the specified prompt. // the specified prompt.
func (c Context) ReadString(promptf string, args ...any) string { func (c Context) ReadString(promptf string, args ...any) string {
var text string var text string
if pref != "" { if promptf != "" {
c.Sendf(promptf, args...) c.Sendf(promptf, args...)
} }
for u := range c.Input() { for u := range c.Input() {
if u == nil {
break
}
if u.Message == nil { if u.Message == nil {
continue continue
} }
@ -198,19 +178,25 @@ func (c Context) ReadString(promptf string, args ...any) string {
return text return text
} }
func (c Context) Update() Update { func (c Context) Update(updater Updater) error {
return c.update return updater.Update(c)
}
func (c Context) CallbackUpdate() *Update {
return &c.update
} }
// Returns the reader for specified file ID and path. // Returns the reader for specified file ID and path.
func (c *Context) GetFile(fileId FileId) (io.ReadCloser, string, error) { func (c Context) GetFile(fileID FileID) (io.ReadCloser, string, error) {
file, err := c.Bot.Api.GetFile(tgbotapi.FileConfig{FileID:string(fileId)}) file, err := c.Bot().API().GetFile(tgbotapi.FileConfig{
FileID: string(fileID),
})
if err != nil { if err != nil {
return nil, "", err return nil, "", err
} }
r, err := http.Get(fmt.Sprintf( r, err := http.Get(fmt.Sprintf(
"https://api.telegram.org/file/bot%s/%s", "https://api.telegram.org/file/bot%s/%s",
c.Bot.Api.Token, c.Bot().API().Token,
file.FilePath, file.FilePath,
)) ))
if err != nil { if err != nil {
@ -223,8 +209,9 @@ func (c *Context) GetFile(fileId FileId) (io.ReadCloser, string, error) {
return r.Body, file.FilePath, nil return r.Body, file.FilePath, nil
} }
func (c *Context) ReadFile(fileId FileId) ([]byte, string, error) { // Reads all the content from the specified file.
file, pth, err := c.GetFile(fileId) func (c Context) ReadFile(fileID FileID) ([]byte, string, error) {
file, pth, err := c.GetFile(fileID)
if err != nil { if err != nil {
return nil, "", err return nil, "", err
} }
@ -238,3 +225,172 @@ func (c *Context) ReadFile(fileId FileId) ([]byte, string, error) {
return bts, pth, nil return bts, pth, nil
} }
func (c Context) RunCompo(compo Component) (*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),
)
// 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) (*UpdateChan, error) {
var err error
if widget == nil {
return nil, EmptyWidgetErr
}
compos := widget.Render(c)
// Leave if changed path or components are empty.
if compos == nil {
return nil, EmptyCompoErr
}
chns := make([]*UpdateChan, len(compos))
for i, compo := range compos {
chns[i], err = c.RunCompo(compo)
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
}
func (c Context) GoRet(pth Widget) UI {
return UI{WidgetGo{
Path: pth,
Arg: c.Arg(),
}}
}
// Go to the specified widget
// using context values.
func (c Context) Go(pth Widget) error {
var err error
if pth == nil {
c.session.pathHistory = []Widget{}
return nil
}
var back bool
if pth == Back {
if len(c.session.pathHistory) <= 1 {
return c.Go(nil)
}
pth = c.session.pathHistory[len(c.session.pathHistory)-2]
c.session.pathHistory =
c.session.pathHistory[:len(c.session.pathHistory)-1]
back = true
}
if !back {
c.session.pathHistory = append(c.session.pathHistory, pth)
}
// Stopping the current widget.
c.session.skippedUpdates.Close()
// Running the new one.
c.session.skippedUpdates, err = c.RunWidget(pth)
if err != nil {
return err
}
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
}
// Return context's session's path history.
func (c Context) PathHistory() []Widget {
return c.session.pathHistory
}
func (c Context) SetPathHistory(hist []Widget) {
c.session.pathHistory = hist
}
func (c Context) Path() Widget {
ln := len(c.session.pathHistory)
if ln == 0 {
return nil
}
return c.session.pathHistory[ln-1]
}

4
devel-loop Executable file
View file

@ -0,0 +1,4 @@
#!/bin/sh
wgo sh -c './btest && ./exe/test'

13
file.go
View file

@ -10,6 +10,7 @@ import (
"github.com/go-telegram-bot-api/telegram-bot-api/v5" "github.com/go-telegram-bot-api/telegram-bot-api/v5"
) )
type FileID string
type FileConfig = tgbotapi.FileConfig type FileConfig = tgbotapi.FileConfig
type PhotoConfig = tgbotapi.PhotoConfig type PhotoConfig = tgbotapi.PhotoConfig
type FileType int type FileType int
@ -40,7 +41,7 @@ type File struct {
func NewFile(reader io.Reader) *File { func NewFile(reader io.Reader) *File {
ret := &File{} ret := &File{}
ret.MessageCompo = NewMessage("") ret.MessageCompo = *Messagef("")
ret.reader = reader ret.reader = reader
ret.upload = true ret.upload = true
@ -107,21 +108,21 @@ func (f *File) SendData() string {
} }
func (f *File) SendConfig( func (f *File) SendConfig(
sid SessionId, bot *Bot, sid SessionID, bot *Bot,
) (SendConfig) { ) (SendConfig) {
var config SendConfig var config SendConfig
cid := sid.ToApi() cid := sid.ToAPI()
switch f.Type() { switch f.Type() {
case PhotoFileType: case PhotoFileType:
photo := tgbotapi.NewPhoto(cid, f) photo := tgbotapi.NewPhoto(cid, f)
photo.Caption = f.caption photo.Caption = f.caption
config.Photo = &photo config.Chattable = photo
case DocumentFileType: case DocumentFileType:
doc := tgbotapi.NewDocument(sid.ToApi(), f) doc := tgbotapi.NewDocument(sid.ToAPI(), f)
doc.Caption = f.caption doc.Caption = f.caption
config.Document = &doc config.Chattable = doc
default: default:
panic(UnknownFileTypeErr) panic(UnknownFileTypeErr)
} }

View file

@ -7,12 +7,12 @@ package tg
type Filterer interface { type Filterer interface {
// Return true if should filter the update // Return true if should filter the update
// and not send it inside the widget. // 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( func (f FilterFunc) Filter(
u *Update, u Update,
) bool { ) bool {
return f(u) return f(u)
} }

29
go.go
View file

@ -1,22 +1,33 @@
package tg package tg
func Go(pth Path) UI { func Go(pth Widget) UI {
return UI{ return UI{
GoWidget(pth), WidgetGo{
Path: pth,
},
} }
} }
type GoWidget string // The type implements changing current path to the widget.
// Implementing the Server interface. type WidgetGo struct {
func (widget GoWidget) Serve(c Context) { Path Widget
c.input.Close() Arg any
c.Go(Path(widget))
} }
func (widget GoWidget) Render(c Context) UI { func (w WidgetGo) Act(c Context) {
c.WithArg(w.Arg).Go(w.Path)
}
// Implementing the Server interface.
func (widget WidgetGo) Serve(c Context) {
c.input.Close()
c.WithArg(widget.Path).Go(widget.Path)
}
func (widget WidgetGo) Render(c Context) UI {
return UI{widget} return UI{widget}
} }
func (widget GoWidget) Filter(u Update) bool { func (widget WidgetGo) Filter(u Update) bool {
return true return true
} }

2
go.mod
View file

@ -1,4 +1,4 @@
module vultras.su/core/tg module surdeus.su/core/tg
go 1.20 go 1.20

View file

@ -11,7 +11,7 @@ type Inline struct {
} }
// Convert the inline keyboard to markup for the tgbotapi. // Convert the inline keyboard to markup for the tgbotapi.
func (kbd Inline) ToApi() tgbotapi.InlineKeyboardMarkup { func (kbd Inline) ToAPI() tgbotapi.InlineKeyboardMarkup {
rows := [][]tgbotapi.InlineKeyboardButton{} rows := [][]tgbotapi.InlineKeyboardButton{}
for _, row := range kbd.Rows { for _, row := range kbd.Rows {
if row == nil { if row == nil {
@ -19,7 +19,7 @@ func (kbd Inline) ToApi() tgbotapi.InlineKeyboardMarkup {
} }
buttons := []tgbotapi.InlineKeyboardButton{} buttons := []tgbotapi.InlineKeyboardButton{}
for _, button := range row { for _, button := range row {
if button == nil { if !button.Valid {
continue continue
} }
buttons = append(buttons, button.ToTelegramInline()) buttons = append(buttons, button.ToTelegramInline())
@ -38,12 +38,14 @@ type InlineCompo struct {
// Implementing the Sendable interface. // Implementing the Sendable interface.
func (compo *InlineCompo) SendConfig( func (compo *InlineCompo) SendConfig(
sid SessionId, bot *Bot, sid SessionID, bot *Bot,
) (SendConfig) { ) (SendConfig) {
sendConfig := compo.MessageCompo.SendConfig(sid, bot) sendConfig := compo.MessageCompo.SendConfig(sid, bot)
msg := sendConfig.Chattable.(tgbotapi.MessageConfig)
if len(compo.Inline.Rows) > 0 { if len(compo.Inline.Rows) > 0 {
sendConfig.Message.ReplyMarkup = compo.Inline.ToApi() msg.ReplyMarkup = compo.Inline.ToAPI()
} }
sendConfig.Chattable = msg
return sendConfig return sendConfig
} }
@ -51,34 +53,39 @@ func (compo *InlineCompo) SendConfig(
// Update the component on the client side. // Update the component on the client side.
// Requires exactly the pointer but not the value // Requires exactly the pointer but not the value
// cause it changes insides of the structure. // cause it changes insides of the structure.
func (compo *InlineCompo) Update(c Context) { func (compo *InlineCompo) Update(c Context) error {
if compo.Message != nil { if compo.Message != nil {
var edit tgbotapi.Chattable var edit tgbotapi.Chattable
markup := compo.Inline.ToApi() markup := compo.Inline.ToAPI()
ln := len(markup.InlineKeyboard) ln := len(markup.InlineKeyboard)
if ln == 0 || compo.Inline.Rows == nil { if ln == 0 || compo.Inline.Rows == nil {
edit = tgbotapi.NewEditMessageText( edit = tgbotapi.NewEditMessageText(
c.Session.Id.ToApi(), c.SessionID().ToAPI(),
compo.Message.MessageID, compo.Message.MessageID,
compo.Text, compo.Text,
) )
} else { } else {
edit = tgbotapi.NewEditMessageTextAndMarkup( edit = tgbotapi.NewEditMessageTextAndMarkup(
c.Session.Id.ToApi(), c.SessionID().ToAPI(),
compo.Message.MessageID, compo.Message.MessageID,
compo.Text, compo.Text,
markup, markup,
) )
} }
msg, _ := c.Bot.Api.Send(edit) msg, err := c.Bot().API().Send(edit)
if err != nil {
return err
}
compo.Message = &msg compo.Message = &msg
} }
compo.buttonMap = compo.MakeButtonMap()
return nil
} }
// Implementing the Filterer interface. // Implementing the Filterer interface.
func (compo InlineCompo) Filter(u Update) bool { func (compo *InlineCompo) Filter(u Update) bool {
if compo == nil || u.CallbackQuery == nil { if u.CallbackQuery == nil {
return true return true
} }
@ -91,13 +98,13 @@ func (compo InlineCompo) Filter(u Update) bool {
} }
// Implementing the Server interface. // Implementing the Server interface.
func (compo InlineCompo) Serve(c Context) { func (compo *InlineCompo) Serve(c Context) {
for u := range c.Input() { for u := range c.Input() {
compo.OnOneUpdate(c, u) compo.OnOneUpdate(c, u)
} }
} }
func (compo *InlineCompo) OnOneUpdate(c Context, u Update) { func (compo *InlineCompo) OnOneUpdate(c Context, u Update) error {
var act Action var act Action
btns := compo.ButtonMap() btns := compo.ButtonMap()
cb := tgbotapi.NewCallback( cb := tgbotapi.NewCallback(
@ -106,20 +113,23 @@ func (compo *InlineCompo) OnOneUpdate(c Context, u Update) {
) )
data := u.CallbackQuery.Data data := u.CallbackQuery.Data
_, err := c.Bot.Api.Request(cb) _, err := c.Bot().API().Request(cb)
if err != nil { if err != nil {
return return err
} }
btn, ok := btns[data] btn, ok := btns[data]
if !ok { if !ok {
return return nil
} }
if btn != nil {
if btn.Action != nil {
act = btn.Action act = btn.Action
} else if compo.Action != nil { } else if compo.Action != nil {
act = compo.Action act = compo.Action
} }
c.WithUpdate(u).Run(act) c.WithUpdate(u).Run(act)
return nil
} }

View file

@ -10,7 +10,7 @@ type InvoiceCompo struct {
} }
func (compo *InvoiceCompo) SendConfig( func (compo *InvoiceCompo) SendConfig(
sid SessionId, bot *Bot, sid SessionID, bot *Bot,
) (*SendConfig) { ) (*SendConfig) {
return nil return nil
} }

View file

@ -10,12 +10,11 @@ type Keyboard struct {
// defined action for the button. // defined action for the button.
Action Action Action Action
Rows []ButtonRow Rows []ButtonRow
buttonMap ButtonMap
} }
// Returns the new keyboard with specified rows. // Returns the new keyboard with specified rows.
func NewKeyboard(rows ...ButtonRow) Keyboard { func NewKeyboard(rows ...ButtonRow) Keyboard {
ret := &Keyboard{} ret := Keyboard{}
for _, row := range rows { for _, row := range rows {
if row != nil && len(row) > 0 { if row != nil && len(row) > 0 {
ret.Rows = append(ret.Rows, row) ret.Rows = append(ret.Rows, row)
@ -41,9 +40,10 @@ func (kbd Keyboard) Row(btns ...Button) Keyboard {
if len(btns) < 1 { if len(btns) < 1 {
return kbd return kbd
} }
retBtns := []*Button{}
retBtns := make([]Button, 0, len(btns))
for _, btn := range btns { for _, btn := range btns {
if btn == nil { if !btn.Valid {
continue continue
} }
retBtns = append(retBtns, btn) retBtns = append(retBtns, btn)
@ -58,7 +58,7 @@ func (kbd Keyboard) Row(btns ...Button) Keyboard {
// Adds buttons as one column list. // Adds buttons as one column list.
func (kbd Keyboard) List(btns ...Button) Keyboard { func (kbd Keyboard) List(btns ...Button) Keyboard {
for _, btn := range btns { for _, btn := range btns {
if btn == nil { if !btn.Valid {
continue continue
} }
kbd.Rows = append(kbd.Rows, ButtonRow{btn}) 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 // Returns the map of buttons. Where the key
// is button data and the value is Action. // is button data and the value is Action.
func (kbd Keyboard) ButtonMap() ButtonMap { func (kbd Keyboard) ButtonMap() ButtonMap {
if kbd.buttonMap == nil { return kbd.MakeButtonMap()
kbd.buttonMap = kbd.MakeButtonMap()
}
return kbd.buttonMap
} }
// Returns the map of buttons on the most fresh version of the keyboard. // 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 ret[vj.Key()] = vj
} }
} }
kbd.buttonMap = ret
return ret return ret
} }
@ -103,8 +99,9 @@ func (kbd Keyboard) Inline() Inline {
} }
// Convert the keyboard to the more specific reply one. // Convert the keyboard to the more specific reply one.
// By default OneTime = true.
func (kbd Keyboard) Reply() Reply { func (kbd Keyboard) Reply() Reply {
ret := &Reply{} ret := Reply{}
ret.Keyboard = kbd ret.Keyboard = kbd
// it is used more often than not once. // it is used more often than not once.
ret.OneTime = true ret.OneTime = true

View file

@ -7,22 +7,22 @@ import (
type Location = tgbotapi.Location type Location = tgbotapi.Location
type LocationCompo struct { type LocationCompo struct {
*MessageCompo MessageCompo
Location Location
} }
func (compo *LocationCompo) SendConfig( func (compo *LocationCompo) SendConfig(
sid SessionId, bot *Bot, sid SessionID, bot *Bot,
) (*SendConfig) { ) (SendConfig) {
cid := sid.ToApi() cid := sid.ToAPI()
loc := tgbotapi.NewLocation( location := tgbotapi.NewLocation(
cid, cid,
compo.Latitude, compo.Latitude,
compo.Longitude, compo.Longitude,
) )
ret := &SendConfig{
Location: &loc, ret := SendConfig{}
} ret.Chattable = location
return ret return ret
} }

View file

@ -10,8 +10,13 @@ type Message = tgbotapi.Message
// Simple text message component type. // Simple text message component type.
type MessageCompo struct { 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 ParseMode string
// The text to display.
Text string Text string
} }
@ -29,111 +34,121 @@ func Escape2(str string) string {
// Call the function after the message was sent. // Call the function after the message was sent.
func (compo *MessageCompo) Update(c Context) error { func (compo *MessageCompo) Update(c Context) error {
edit := tgbotapi.NewEditMessageText( edit := tgbotapi.NewEditMessageText(
c.Session.Id.ToApi(), c.Session().ID.ToAPI(),
compo.Message.MessageID, compo.Message.MessageID,
compo.Text, compo.Text,
) )
msg, err := c.bot.api.Send(edit) msg, err := c.Bot().API().Send(edit)
if err != nil { if err != nil {
return err return err
} }
compo.Message = msg compo.Message = &msg
return nil return nil
} }
func (compo *MessageCompo) Delete(c *Context) { // Calling the method removes the message on the client side
cfg := tgbotapi.NewDeleteMessage(c.Session.Id.ToApi(), compo.Message.MessageID) // and sets the Message in the component to nil.
c.Bot.Api.Send(cfg) func (compo *MessageCompo) Delete(c Context) error {
//c.Sendf("%q", err) 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 new message with the specified text
// return of rendering functions. // formatted with the fmt.Sprintf function.
func (compo *MessageCompo) SetMessage(msg Message) { func Messagef(format string, v ...any) *MessageCompo {
compo.Message = msg ret := &MessageCompo{}
}
// Return new message with the specified text.
func Messagef(format string, v ...any) MessageCompo {
ret := MessageCompo{}
ret.Text = fmt.Sprintf(format, v...) ret.Text = fmt.Sprintf(format, v...)
ret.ParseMode = tgbotapi.ModeMarkdown ret.ParseMode = tgbotapi.ModeMarkdown
return ret return ret
} }
// Return message with the specified parse mode. // Return message with the specified parse mode.
func (msg MessageCompo) withParseMode(mode string) MessageCompo { func (compo *MessageCompo) setParseMode(mode string) *MessageCompo {
msg.ParseMode = mode compo.ParseMode = mode
return msg return compo
} }
// Set the default Markdown parsing mode. // Set the default Markdown parsing mode.
func (msg MessageCompo) MD() MessageCompo { func (compo *MessageCompo) MD() *MessageCompo {
return msg.withParseMode(tgbotapi.ModeMarkdown) return compo.setParseMode(tgbotapi.ModeMarkdown)
} }
// Set the Markdown 2 parsing mode. // Set the Markdown 2 parsing mode.
func (msg MessageCompo) MD2() MessageCompo { func (compo *MessageCompo) MD2() *MessageCompo {
return msg.withParseMode(tgbotapi.ModeMarkdownV2) return compo.setParseMode(tgbotapi.ModeMarkdownV2)
} }
// Set the HTML parsing mode. // Set the HTML parsing mode.
func (msg MessageCompo) HTML() MessageCompo { func (compo *MessageCompo) HTML() *MessageCompo {
return msg.withParseMode(tgbotapi.ModeHTML) return compo.setParseMode(tgbotapi.ModeHTML)
} }
// Transform the message component into one with reply keyboard. // Transform the message component into one with reply keyboard.
func (msg MessageCompo) Inline(inline Inline) InlineCompo { func (compo *MessageCompo) Inline(inline Inline) *InlineCompo {
return InlineCompo{ return &InlineCompo{
Inline: inline, Inline: inline,
MessageCompo: msg, MessageCompo: *compo,
} }
} }
// Transform the message component into one with reply keyboard. // Transform the message component into one with reply keyboard.
func (msg MessageCompo) Reply(reply Reply) ReplyCompo { func (msg *MessageCompo) Reply(reply Reply) *ReplyCompo {
return ReplyCompo{ return &ReplyCompo{
Reply: reply, Reply: reply,
MessageCompo: msg, MessageCompo: *msg,
} }
} }
// Transform the message component into the location one. // Transform the message component into the location one.
func (msg MessageCompo) Location( func (msg *MessageCompo) Location(
lat, long float64, lat, long float64,
) LocationCompo { ) *LocationCompo {
ret := &LocationCompo{ ret := &LocationCompo{}
MessageCompo: msg, ret.MessageCompo = *msg
Location: Location{ ret.Latitude = lat
Latitude: lat, ret.Longitude = long
Longitude: long,
},
}
return ret return ret
} }
// Implementing the Sendable interface. // Implementing the Sendable interface.
func (config MessageCompo) SendConfig( func (compo *MessageCompo) SendConfig(
sid SessionId, bot *Bot, sid SessionID, bot *Bot,
) (SendConfig) { ) (SendConfig) {
var ( var (
ret SendConfig ret SendConfig
text string text string
) )
if config.Text == "" { // Protection against empty text,
// since it breaks the Telegram bot API.
if compo.Text == "" {
text = ">" text = ">"
} else { } else {
text = config.Text text = compo.Text
} }
msg := tgbotapi.NewMessage(sid.ToApi(), text) msg := tgbotapi.NewMessage(sid.ToAPI(), text)
msg.ParseMode = config.ParseMode msg.ParseMode = compo.ParseMode
ret.Chattable = msg ret.Chattable = msg
return ret 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. // Empty serving to use messages in rendering.
func (compo *MessageCompo) Serve(c Context) {} func (compo *MessageCompo) Serve(c Context) {}

113
paged-panel.go Normal file
View file

@ -0,0 +1,113 @@
package tg
type PanelPager interface {
GetPanelPage(
panel *PanelPagerCompo,
c Context, page, size int,
) PanelPage
}
type PanelPage struct {
Next, Prev bool
Rows []ButtonRow
}
type PanelPagerFunc func(
panel *PanelPagerCompo,
c Context, page, size int,
) PanelPage
func (fn PanelPagerFunc) GetPanelPage(
panel *PanelPagerCompo, c Context, page, size int,
) PanelPage {
return fn(panel, c, page, size)
}
type PanelPagerCompo struct {
PanelCompo
page int
size int
nextFormat, prevFormat, delFormat string
pager PanelPager
}
func (compo *MessageCompo) PanelPager(
c Context,
startPage, size int,
pager PanelPager,
) (*PanelPagerCompo) {
ret := &PanelPagerCompo{}
ret.page = startPage
ret.size = size
ret.pager = pager
ret.prevFormat = "<<<"
ret.nextFormat = ">>>"
ret.delFormat = "..."
ret.PanelCompo = (*compo.Panel(
c, ret,
))
return ret
}
func (panel *PanelPagerCompo) GetPanelRows(
pcompo *PanelCompo, c Context,
) []ButtonRow {
page := panel.pager.GetPanelPage(
panel, c, panel.page, panel.size,
)
controlRow := ButtonRow{}
rows := page.Rows
next := func(c Context){
panel.page++
panel.Update(c)
}
prev := func(c Context){
panel.page--
panel.Update(c)
}
if page.Prev {
if panel.delFormat != "" {
rows = append(
[]ButtonRow{
ButtonRow{
Buttonf(panel.delFormat).Rand().
WithAction(Func(prev)),
},
},
rows...,
)
}
controlRow = append(
controlRow,
Buttonf(panel.prevFormat).Rand().
WithAction(Func(prev)),
)
}
if page.Next {
if panel.delFormat != "" {
rows = append(
rows,
ButtonRow{
Buttonf(panel.delFormat).Rand().
WithAction(Func(next)),
},
)
}
controlRow = append(
controlRow,
Buttonf(panel.nextFormat).Rand().
WithAction(Func(next)),
)
}
return append(
rows,
controlRow,
)
}

View file

@ -1,40 +1,55 @@
package tg package tg
// Using the interface and all related is
// deprecated. Use the Paneler interface and function.
type Rowser interface { type Rowser interface {
MakeRows(c *Context) []ButtonRow MakeRows(c Context) []ButtonRow
} }
type RowserFunc func(c *Context) []ButtonRow type RowserFunc func(c Context) []ButtonRow
func (fn RowserFunc) MakeRows(c *Context) []ButtonRow { func (fn RowserFunc) MakeRows(c Context) []ButtonRow {
return fn(c) return fn(c)
} }
type Paneler interface {
GetPanelRows(*PanelCompo, Context) []ButtonRow
}
type PanelFunc func(*PanelCompo, Context) []ButtonRow
func (fn PanelFunc) GetPanelRows(
panel *PanelCompo, c Context,
) []ButtonRow {
return fn(panel, c)
}
// The type represents the inline panel with // The type represents the inline panel with
// scrollable via buttons content. // scrollable via buttons content.
// Can be used for example to show users via SQL and offset // Can be used for example to show users via SQL and offset
// or something like that. // or something like that.
type PanelCompo struct { type PanelCompo struct {
*InlineCompo InlineCompo
Rowser Rowser Paneler Paneler
} }
// Transform to the panel with dynamic rows. // Transform to the panel with dynamic rows.
func (compo *MessageCompo) Panel( 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. paneler Paneler, // The rows generator.
) *PanelCompo { ) *PanelCompo {
ret := &PanelCompo{} ret := &PanelCompo{}
ret.InlineCompo = compo.Inline( ret.Paneler = paneler
ret.InlineCompo = (*compo.Inline(
NewKeyboard( NewKeyboard(
rowser.MakeRows(c)..., ret.Paneler.GetPanelRows(ret, c)...,
).Inline(), ).Inline(),
) ))
ret.Rowser = rowser
return ret return ret
} }
func (compo *PanelCompo) Update(c *Context) { // Implementing the Updater.
compo.Rows = compo.Rowser.MakeRows(c) func (panel *PanelCompo) Update(c Context) error {
compo.InlineCompo.Update(c) panel.Rows = panel.Paneler.GetPanelRows(panel, c)
return panel.InlineCompo.Update(c)
} }

View file

@ -41,7 +41,7 @@ func (kbd Reply) ToApi() any {
} }
buttons := []tgbotapi.KeyboardButton{} buttons := []tgbotapi.KeyboardButton{}
for _, button := range row { for _, button := range row {
if button == nil { if !button.Valid {
continue continue
} }
buttons = append(buttons, button.ToTelegram()) buttons = append(buttons, button.ToTelegram())
@ -63,17 +63,21 @@ type ReplyCompo struct {
} }
// Implementing the sendable interface. // Implementing the sendable interface.
func (compo ReplyCompo) SendConfig( func (compo *ReplyCompo) SendConfig(
sid SessionId, bot *Bot, sid SessionID, bot *Bot,
) (SendConfig) { ) (SendConfig) {
sendConfig := compo.MessageCompo.SendConfig(sid, bot) 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 return sendConfig
} }
// Implementing the Server interface. // Implementing the Server interface.
func (compo ReplyCompo) Filter( func (compo *ReplyCompo) Filter(
u *Update, u Update,
) bool { ) bool {
if compo == nil || u.Message == nil { if compo == nil || u.Message == nil {
return true return true
@ -82,8 +86,8 @@ func (compo ReplyCompo) Filter(
_, ok := compo.ButtonMap()[u.Message.Text] _, ok := compo.ButtonMap()[u.Message.Text]
if !ok { if !ok {
if u.Message.Location != nil { if u.Message.Location != nil {
locBtn := compo.ButtonMap().LocationButton() _, hasLocBtn := compo.ButtonMap().LocationButton()
if locBtn == nil { if !hasLocBtn {
return true return true
} }
} else { } else {
@ -94,22 +98,27 @@ func (compo ReplyCompo) Filter(
} }
// Implementing the UI interface. // Implementing the UI interface.
func (compo ReplyCompo) Serve(c *Context) { func (compo *ReplyCompo) Serve(c Context) {
for u := range c.Input() { for u := range c.Input() {
var btn *Button var btn Button
text := u.Message.Text text := u.Message.Text
btns := compo.ButtonMap() btns := compo.ButtonMap()
btn, ok := btns[text] btn, ok := btns[text]
if !ok { if !ok {
if u.Message.Location != nil { if u.Message.Location != nil {
btn = btns.LocationButton() locBtn, hasLocBtn := btns.LocationButton()
if hasLocBtn {
btn = locBtn
}
} }
} }
if btn != nil { if !btn.Valid {
c.WithUpdate(u).Run(btn.Action) continue
} }
c.WithUpdate(u).Run(btn.Action)
} }
} }

View file

@ -1,48 +1,32 @@
package tg package tg
import ( type WidgetSpecial int
"path" const (
widgetEmpty WidgetSpecial = iota
widgetBack
) )
// The type implements changing screen to the underlying ScreenId
type ScreenGo struct { func (w WidgetSpecial) Render(_ Context) UI {
Path Path return nil
Args []any
} }
func (sc ScreenGo) Act(c *Context) { var (
c.Go(sc.Path, sc.Args...) Back = Widget(widgetBack)
} )
// The same as Act. /*// Unique identifier for the screen.
func (sc ScreenGo) Serve(c *Context) { type Path int
sc.Act(c) const (
} PathEmpty Path = 0
// Going to the path returns
// Unique identifier for the screen // a context to the previous screen.
// and relative paths to the screen. PathBack Path = -1
type Path string )
// Returns true if the path is empty. // Returns true if the path is empty.
func (p Path) IsEmpty() bool { func (p Path) IsEmpty() bool {
return p == "" return p == 0
}
// Returns true if the path is absolute.
func (p Path) IsAbs() bool {
if len(p) == 0 {
return false
}
return p[0] == '/'
}
func (p Path) Dir() Path {
return Path(path.Dir(string(p)))
}
// Clean the path deleting exceed ., .. and / .
func (p Path) Clean() Path {
return Path(path.Clean(string(p)))
} }
// Screen statement of the bot. // Screen statement of the bot.
@ -64,10 +48,10 @@ type Node struct {
Path Path Path Path
Screen *Screen Screen *Screen
Subs []*Node Subs []*Node
} }*/
// Return new root node with the specified widget in the screen. // Return new root node with the specified widget in the screen.
func NewRootNode(widget Widget, subs ...*Node) *RootNode { /*func NewRootNode(widget Widget, subs ...*Node) *RootNode {
ret := &RootNode{} ret := &RootNode{}
ret.Screen = NewScreen(widget) ret.Screen = NewScreen(widget)
ret.Subs = subs ret.Subs = subs
@ -114,15 +98,13 @@ func (n *Node) ScreenMap(root Path) ScreenMap {
} }
} }
return m return m
} }*/
// Map structure for the screens.
type ScreenMap map[Path] *Screen
// Returns the new screen with specified name and widget. // Returns the new screen with specified name and widget.
func NewScreen(widget Widget) *Screen { /*func NewScreen(widget Widget) *Screen {
return &Screen{ return &Screen{
Widget: widget, Widget: widget,
} }
} }*/

View file

@ -10,8 +10,8 @@ type MessageId int64
// way to define what message will be // way to define what message will be
// sent to the side of a user. // sent to the side of a user.
type Sendable interface { type Sendable interface {
SendConfig(SessionId, *Bot) (SendConfig) SendConfig(SessionID, *Bot) (SendConfig)
SetMessage(Message) SetMessage(*Message)
} }
// The type is used as an endpoint to send messages // The type is used as an endpoint to send messages
@ -25,7 +25,7 @@ type SendConfig struct {
type MessageMap map[string] *Message type MessageMap map[string] *Message
// Convert to the bot.Api.Send format. // Convert to the bot.Api.Send format.
func (config SendConfig) ToApi() tgbotapi.Chattable { func (config SendConfig) ToAPI() tgbotapi.Chattable {
return config.Chattable return config.Chattable
} }

View file

@ -3,6 +3,6 @@ package tg
// Implementing the interface provides // Implementing the interface provides
// the way to define how to handle updates. // the way to define how to handle updates.
type Server interface { type Server interface {
Serve(*Context) Serve(Context)
} }

View file

@ -2,11 +2,15 @@ package tg
// The type represents map of sessions using // The type represents map of sessions using
// as key. // as key.
type SessionMap map[SessionId]*Session type SessionMap map[SessionID]*Session
// Add new empty session by it's ID. // Add new empty session by it's ID.
func (sm SessionMap) Add(sid SessionId, scope SessionScope) *Session { func (sm SessionMap) Add(
ret := NewSession(sid, scope) bot *Bot,
sid SessionID,
scope SessionScope,
) *Session {
ret := NewSession(bot, sid, scope)
sm[sid] = ret sm[sid] = ret
return ret return ret
} }
@ -23,159 +27,37 @@ const (
// Represents unique value to identify chats. // Represents unique value to identify chats.
// In fact is simply ID of the chat. // In fact is simply ID of the chat.
type SessionId int64 type SessionID int64
// Convert the SessionId to Telegram API's type. // Convert the SessionID to Telegram API's type.
func (si SessionId) ToApi() int64 { func (si SessionID) ToAPI() int64 {
return int64(si) return int64(si)
} }
// The type represents current state of // The type represents current state of
// user interaction per each of them. // user interaction per each of them.
type Session struct { type Session struct {
// Id of the chat of the user. // ID of the chat of the user.
Id SessionId ID SessionID
Scope SessionScope Scope SessionScope
// Custom value for each user. // Custom value for each user.
Data any Data any
bot *Bot bot *Bot
pathHistory []Path pathHistory []Widget
skippedUpdates *UpdateChan skippedUpdates *UpdateChan
updates *UpdateChan updates *UpdateChan
} }
// Return new empty session with specified user ID. // Return new empty session.
func NewSession(id SessionId, scope SessionScope) *Session { func NewSession(bot *Bot, id SessionID, scope SessionScope) *Session {
return &Session{ ret := &Session{}
Id: id, ret.ID = id
Scope: scope, 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
}

View file

@ -1,6 +0,0 @@
version: 3
tasks:
build:
cmds:
- go build -o testbot ./cmd/test/

6
ui.go
View file

@ -3,12 +3,12 @@ package tg
// The type describes dynamic screen widget // The type describes dynamic screen widget
// That can have multiple UI components. // That can have multiple UI components.
type Widget interface { type Widget interface {
Render(*Context) UI Render(Context) UI
} }
// The way to describe custom function based Widgets. // The way to describe custom function based Widgets.
type RenderFunc func(c *Context) UI type RenderFunc func(c Context) UI
func (fn RenderFunc) Render(c *Context) UI { func (fn RenderFunc) Render(c Context) UI {
return fn(c) return fn(c)
} }

View file

@ -2,7 +2,6 @@ package tg
import tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" import tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
type FileId string
type Update struct { type Update struct {
tgbotapi.Update tgbotapi.Update
@ -16,18 +15,18 @@ type UpdateChan struct {
// Return new update channel. // Return new update channel.
func NewUpdateChan() *UpdateChan { func NewUpdateChan() *UpdateChan {
ret := &UpdateChan{} ret := &UpdateChan{}
ret.chn = make(chan *Update) ret.chn = make(chan Update)
return ret return ret
} }
func (updates *UpdateChan) Chan() chan *Update { func (updates *UpdateChan) Chan() chan Update {
return updates.chn return updates.chn
} }
// Send an update to the channel. // Send an update to the channel.
// Returns true if the update was sent. // Returns true if the update was sent.
func (updates *UpdateChan) Send(u *Update) bool { func (updates *UpdateChan) Send(u Update) bool {
defer recover() defer recover()
if updates == nil || updates.chn == nil { if updates == nil || updates.chn == nil {
return false return false
@ -37,11 +36,11 @@ func (updates *UpdateChan) Send(u *Update) bool {
} }
// Read an update from the channel. // Read an update from the channel.
func (updates *UpdateChan) Read() *Update { func (updates *UpdateChan) Read() (Update, bool) {
if updates == nil || updates.chn == nil { 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. // Returns true if the channel is closed.
@ -60,13 +59,12 @@ func (updates *UpdateChan) Close() {
} }
func (u Update) HasDocument() bool { func (u Update) HasDocument() bool {
return u != nil && return u.Message != nil &&
u.Message != nil &&
u.Message.Document != nil u.Message.Document != nil
} }
func (u Update) DocumentId() FileId { func (u Update) DocumentID() FileID {
return FileId(u.Update.Message.Document.FileID) return FileID(u.Update.Message.Document.FileID)
} }
func (u *Update) DocumentName() string { func (u *Update) DocumentName() string {
@ -86,10 +84,10 @@ func (u Update) HasPhotos() bool {
len(u.Message.Photo) != 0 len(u.Message.Photo) != 0
} }
func (u Update) PhotoIds() []FileId { func (u Update) PhotoIDs() []FileID {
ret := make([]FileId, len(u.Message.Photo)) ret := make([]FileID, len(u.Message.Photo))
for i, photo := range u.Message.Photo { for i, photo := range u.Message.Photo {
ret[i] = FileId(photo.FileID) ret[i] = FileID(photo.FileID)
} }
return ret return ret
} }

9
updater.go Normal file
View file

@ -0,0 +1,9 @@
package tg
// Implementing the type provides
// way to update stuff on the client side.
// Things like panels, messages etc.
type Updater interface {
Update(Context) error
}