package layout import ( "bytes" "encoding/json" "io/ioutil" "log" "strings" "sync" "text/template" "github.com/goccy/go-yaml" tele "gopkg.in/telebot.v3" ) type ( // Layout provides an interface to interact with the layout, // parsed from the config file and locales. Layout struct { pref *tele.Settings mu sync.RWMutex // protects ctxs ctxs map[tele.Context]string funcs template.FuncMap commands map[string]string buttons map[string]Button markups map[string]Markup results map[string]Result locales map[string]*template.Template *Config } // Button is a shortcut for tele.Btn. Button = tele.Btn // Markup represents layout-specific markup to be parsed. Markup struct { inline *bool keyboard *template.Template ResizeKeyboard *bool `yaml:"resize_keyboard,omitempty"` // nil == true ForceReply bool `yaml:"force_reply,omitempty"` OneTimeKeyboard bool `yaml:"one_time_keyboard,omitempty"` RemoveKeyboard bool `yaml:"remove_keyboard,omitempty"` Selective bool `yaml:"selective,omitempty"` } // Result represents layout-specific result to be parsed. Result struct { result *template.Template ResultBase `yaml:",inline"` Markup string `yaml:"markup"` } // ResultBase represents layout-specific result's base to be parsed. ResultBase struct { tele.ResultBase `yaml:",inline"` Content ResultContent `yaml:"content"` } // ResultContent represents any kind of InputMessageContent and implements it. ResultContent map[string]interface{} ) // New parses the given layout file. func New(path string, funcs ...template.FuncMap) (*Layout, error) { data, err := ioutil.ReadFile(path) if err != nil { return nil, err } lt := Layout{ ctxs: make(map[tele.Context]string), funcs: make(template.FuncMap), } for k, v := range builtinFuncs { lt.funcs[k] = v } for i := range funcs { for k, v := range funcs[i] { lt.funcs[k] = v } } return <, yaml.Unmarshal(data, <) } // NewDefault parses the given layout file without localization features. // See Layout.Default for more details. func NewDefault(path, locale string, funcs ...template.FuncMap) (*DefaultLayout, error) { lt, err := New(path, funcs...) if err != nil { return nil, err } return lt.Default(locale), nil } var builtinFuncs = template.FuncMap{ // Built-in blank and helper functions. "locale": func() string { return "" }, "config": func(string) string { return "" }, "text": func(string, ...interface{}) string { return "" }, } // Settings returns built telebot Settings required for bot initializing. // // settings: // url: (custom url if needed) // token: (not recommended) // updates: (chan capacity) // locales_dir: (optional) // token_env: (token env var name, example: TOKEN) // parse_mode: (default parse mode) // long_poller: (long poller settings) // webhook: (or webhook settings) // // Usage: // lt, err := layout.New("bot.yml") // b, err := tele.NewBot(lt.Settings()) // // That's all! // func (lt *Layout) Settings() tele.Settings { if lt.pref == nil { panic("telebot/layout: settings is empty") } return *lt.pref } // Default returns a simplified layout instance with the pre-defined locale. // It's useful when you have no need for localization and don't want to pass // context each time you use layout functions. func (lt *Layout) Default(locale string) *DefaultLayout { return &DefaultLayout{ locale: locale, lt: lt, Config: lt.Config, } } // Locales returns all presented locales. func (lt *Layout) Locales() []string { var keys []string for k := range lt.locales { keys = append(keys, k) } return keys } // Locale returns the context locale. func (lt *Layout) Locale(c tele.Context) (string, bool) { lt.mu.RLock() defer lt.mu.RUnlock() locale, ok := lt.ctxs[c] return locale, ok } // SetLocale allows you to change a locale for the passed context. func (lt *Layout) SetLocale(c tele.Context, locale string) { lt.mu.Lock() lt.ctxs[c] = locale lt.mu.Unlock() } // Commands returns a list of telebot commands, which can be // used in b.SetCommands later. func (lt *Layout) Commands() (cmds []tele.Command) { for k, v := range lt.commands { cmds = append(cmds, tele.Command{ Text: strings.TrimLeft(k, "/"), Description: v, }) } return } // Text returns a text, which locale is dependent on the context. // The given optional argument will be passed to the template engine. // // Example of en.yml: // start: Hi, {{.FirstName}}! // // Usage: // func onStart(c tele.Context) error { // return c.Send(lt.Text(c, "start", c.Sender())) // } // func (lt *Layout) Text(c tele.Context, k string, args ...interface{}) string { locale, ok := lt.Locale(c) if !ok { return "" } return lt.TextLocale(locale, k, args...) } // TextLocale returns a localized text processed with text/template engine. // See Text for more details. func (lt *Layout) TextLocale(locale, k string, args ...interface{}) string { tmpl, ok := lt.locales[locale] if !ok { return "" } var arg interface{} if len(args) > 0 { arg = args[0] } var buf bytes.Buffer if err := lt.template(tmpl, locale).ExecuteTemplate(&buf, k, arg); err != nil { log.Println("telebot/layout:", err) } return buf.String() } // Callback returns a callback endpoint used to handle buttons. // // Example: // // Handling settings button // b.Handle(lt.Callback("settings"), onSettings) // func (lt *Layout) Callback(k string) tele.CallbackEndpoint { btn, ok := lt.buttons[k] if !ok { return nil } return &btn } // Button returns a button, which locale is dependent on the context. // The given optional argument will be passed to the template engine. // // buttons: // item: // unique: item // callback_data: {{.ID}} // text: Item #{{.Number}} // // Usage: // btns := make([]tele.Btn, len(items)) // for i, item := range items { // btns[i] = lt.Button(c, "item", struct { // Number int // Item Item // }{ // Number: i, // Item: item, // }) // } // // m := b.NewMarkup() // m.Inline(m.Row(btns...)) // // Your generated markup is ready. // func (lt *Layout) Button(c tele.Context, k string, args ...interface{}) *tele.Btn { locale, ok := lt.Locale(c) if !ok { return nil } return lt.ButtonLocale(locale, k, args...) } // ButtonLocale returns a localized button processed with text/template engine. // See Button for more details. func (lt *Layout) ButtonLocale(locale, k string, args ...interface{}) *tele.Btn { btn, ok := lt.buttons[k] if !ok { return nil } var arg interface{} if len(args) > 0 { arg = args[0] } data, err := yaml.Marshal(btn) if err != nil { log.Println("telebot/layout:", err) return nil } tmpl, err := lt.template(template.New(k), locale).Funcs(lt.funcs).Parse(string(data)) if err != nil { log.Println("telebot/layout:", err) return nil } var buf bytes.Buffer if err := tmpl.Execute(&buf, arg); err != nil { log.Println("telebot/layout:", err) return nil } if err := yaml.Unmarshal(buf.Bytes(), &btn); err != nil { log.Println("telebot/layout:", err) return nil } return &btn } // Markup returns a markup, which locale is dependent on the context. // The given optional argument will be passed to the template engine. // // buttons: // settings: 'Settings' // markups: // menu: // - [settings] // // Usage: // func onStart(c tele.Context) error { // return c.Send( // lt.Text(c, "start"), // lt.Markup(c, "menu"), // ) // } // func (lt *Layout) Markup(c tele.Context, k string, args ...interface{}) *tele.ReplyMarkup { locale, ok := lt.Locale(c) if !ok { return nil } return lt.MarkupLocale(locale, k, args...) } // MarkupLocale returns a localized markup processed with text/template engine. // See Markup for more details. func (lt *Layout) MarkupLocale(locale, k string, args ...interface{}) *tele.ReplyMarkup { markup, ok := lt.markups[k] if !ok { return nil } var arg interface{} if len(args) > 0 { arg = args[0] } var buf bytes.Buffer if err := lt.template(markup.keyboard, locale).Execute(&buf, arg); err != nil { log.Println("telebot/layout:", err) } r := &tele.ReplyMarkup{} if *markup.inline { if err := yaml.Unmarshal(buf.Bytes(), &r.InlineKeyboard); err != nil { log.Println("telebot/layout:", err) } } else { r.ResizeKeyboard = markup.ResizeKeyboard == nil || *markup.ResizeKeyboard r.ForceReply = markup.ForceReply r.OneTimeKeyboard = markup.OneTimeKeyboard r.RemoveKeyboard = markup.RemoveKeyboard r.Selective = markup.Selective if err := yaml.Unmarshal(buf.Bytes(), &r.ReplyKeyboard); err != nil { log.Println("telebot/layout:", err) } } return r } // Result returns an inline result, which locale is dependent on the context. // The given optional argument will be passed to the template engine. // // results: // type: article // id: '{{ .ID }}' // title: '{{ .Title }}' // description: '{{ .Description }}' // message_text: '{{ .Content }}' // thumb_url: '{{ .PreviewURL }}' // // Usage: // func onQuery(c tele.Context) error { // results := make(tele.Results, len(articles)) // for i, article := range articles { // results[i] = lt.Result(c, "article", article) // } // return c.Answer(&tele.QueryResponse{ // Results: results, // CacheTime: 100, // }) // } // func (lt *Layout) Result(c tele.Context, k string, args ...interface{}) tele.Result { locale, ok := lt.Locale(c) if !ok { return nil } return lt.ResultLocale(locale, k, args...) } // ResultLocale returns a localized result processed with text/template engine. // See Result for more details. func (lt *Layout) ResultLocale(locale, k string, args ...interface{}) tele.Result { result, ok := lt.results[k] if !ok { return nil } var arg interface{} if len(args) > 0 { arg = args[0] } var buf bytes.Buffer if err := lt.template(result.result, locale).Execute(&buf, arg); err != nil { log.Println("telebot/layout:", err) } var ( data = buf.Bytes() base ResultBase r tele.Result ) if err := yaml.Unmarshal(data, &base); err != nil { log.Println("telebot/layout:", err) } switch base.Type { case "article": r = &tele.ArticleResult{ResultBase: base.ResultBase} if err := yaml.Unmarshal(data, r); err != nil { log.Println("telebot/layout:", err) } case "audio": r = &tele.AudioResult{ResultBase: base.ResultBase} if err := yaml.Unmarshal(data, r); err != nil { log.Println("telebot/layout:", err) } case "contact": r = &tele.ContactResult{ResultBase: base.ResultBase} if err := yaml.Unmarshal(data, r); err != nil { log.Println("telebot/layout:", err) } case "document": r = &tele.DocumentResult{ResultBase: base.ResultBase} if err := yaml.Unmarshal(data, r); err != nil { log.Println("telebot/layout:", err) } case "gif": r = &tele.GifResult{ResultBase: base.ResultBase} if err := yaml.Unmarshal(data, r); err != nil { log.Println("telebot/layout:", err) } case "location": r = &tele.LocationResult{ResultBase: base.ResultBase} if err := json.Unmarshal(data, &r); err != nil { log.Println("telebot/layout:", err) } case "mpeg4_gif": r = &tele.Mpeg4GifResult{ResultBase: base.ResultBase} if err := yaml.Unmarshal(data, r); err != nil { log.Println("telebot/layout:", err) } case "photo": r = &tele.PhotoResult{ResultBase: base.ResultBase} if err := yaml.Unmarshal(data, r); err != nil { log.Println("telebot/layout:", err) } case "venue": r = &tele.VenueResult{ResultBase: base.ResultBase} if err := yaml.Unmarshal(data, r); err != nil { log.Println("telebot/layout:", err) } case "video": r = &tele.VideoResult{ResultBase: base.ResultBase} if err := yaml.Unmarshal(data, r); err != nil { log.Println("telebot/layout:", err) } case "voice": r = &tele.VoiceResult{ResultBase: base.ResultBase} if err := yaml.Unmarshal(data, r); err != nil { log.Println("telebot/layout:", err) } case "sticker": r = &tele.StickerResult{ResultBase: base.ResultBase} if err := yaml.Unmarshal(data, r); err != nil { log.Println("telebot/layout:", err) } default: log.Println("telebot/layout: unsupported inline result type") return nil } if base.Content != nil { r.SetContent(base.Content) } if result.Markup != "" { markup := lt.MarkupLocale(locale, result.Markup, args...) if markup == nil { log.Printf("telebot/layout: markup with name %s was not found\n", result.Markup) } else { r.SetReplyMarkup(markup) } } return r } func (lt *Layout) template(tmpl *template.Template, locale string) *template.Template { funcs := make(template.FuncMap) // Redefining built-in blank functions funcs["config"] = lt.String funcs["text"] = func(k string, args ...interface{}) string { return lt.TextLocale(locale, k, args...) } funcs["locale"] = func() string { return locale } return tmpl.Funcs(funcs) } // IsInputMessageContent implements telebot.InputMessageContent. func (ResultContent) IsInputMessageContent() bool { return true }