This commit is contained in:
Кобелев Андрей Андреевич
2023-02-01 12:08:20 +03:00
commit 1b497f2b9b
57 changed files with 10640 additions and 0 deletions

122
layout/config.go Normal file
View File

@ -0,0 +1,122 @@
package layout
import (
"strconv"
"time"
tele "git.belvedersky.ru/common/telebot"
"github.com/spf13/viper"
)
// Config represents typed map interface related to the "config" section in layout.
type Config struct {
v *viper.Viper
}
// Unmarshal parses the whole config into the out value. It's useful when you want to
// describe and to pre-define the fields in your custom configuration struct.
func (c *Config) Unmarshal(v interface{}) error {
return c.v.Unmarshal(v)
}
// UnmarshalKey parses the specific key in the config into the out value.
func (c *Config) UnmarshalKey(k string, v interface{}) error {
return c.v.UnmarshalKey(k, v)
}
// Get returns a child map field wrapped into Config.
// If the field isn't a map, returns nil.
func (c *Config) Get(k string) *Config {
v := c.v.Sub(k)
if v == nil {
return nil
}
return &Config{v: v}
}
// Slice returns a child slice of objects wrapped into Config.
// If the field isn't a slice, returns nil.
func (c *Config) Slice(k string) (slice []*Config) {
a, ok := c.v.Get(k).([]interface{})
if !ok {
return nil
}
for i := range a {
m, ok := a[i].(map[string]interface{})
if !ok {
return nil
}
v := viper.New()
v.MergeConfigMap(m)
slice = append(slice, &Config{v: v})
}
return
}
// String returns a field casted to the string.
func (c *Config) String(k string) string {
return c.v.GetString(k)
}
// Int returns a field casted to the int.
func (c *Config) Int(k string) int {
return c.v.GetInt(k)
}
// Int64 returns a field casted to the int64.
func (c *Config) Int64(k string) int64 {
return c.v.GetInt64(k)
}
// Float returns a field casted to the float64.
func (c *Config) Float(k string) float64 {
return c.v.GetFloat64(k)
}
// Bool returns a field casted to the bool.
func (c *Config) Bool(k string) bool {
return c.v.GetBool(k)
}
// Duration returns a field casted to the time.Duration.
// Accepts number-represented duration or a string in 0nsuµmh format.
func (c *Config) Duration(k string) time.Duration {
return c.v.GetDuration(k)
}
// ChatID returns a field casted to the ChatID.
// The value must be an integer.
func (c *Config) ChatID(k string) tele.ChatID {
return tele.ChatID(c.Int64(k))
}
// Strings returns a field casted to the string slice.
func (c *Config) Strings(k string) []string {
return c.v.GetStringSlice(k)
}
// Ints returns a field casted to the int slice.
func (c *Config) Ints(k string) []int {
return c.v.GetIntSlice(k)
}
// Int64s returns a field casted to the int64 slice.
func (c *Config) Int64s(k string) (ints []int64) {
for _, s := range c.Strings(k) {
i, _ := strconv.ParseInt(s, 10, 64)
ints = append(ints, i)
}
return ints
}
// Floats returns a field casted to the float slice.
func (c *Config) Floats(k string) (floats []float64) {
for _, s := range c.Strings(k) {
i, _ := strconv.ParseFloat(s, 64)
floats = append(floats, i)
}
return floats
}

43
layout/default.go Normal file
View File

@ -0,0 +1,43 @@
package layout
import (
tele "git.belvedersky.ru/common/telebot"
)
// DefaultLayout is a simplified layout instance with pre-defined locale by default.
type DefaultLayout struct {
locale string
lt *Layout
Config
}
// Settings returns layout settings.
func (dlt *DefaultLayout) Settings() tele.Settings {
return dlt.lt.Settings()
}
// Text wraps localized layout function Text using your default locale.
func (dlt *DefaultLayout) Text(k string, args ...interface{}) string {
return dlt.lt.TextLocale(dlt.locale, k, args...)
}
// Callback returns a callback endpoint used to handle buttons.
func (dlt *DefaultLayout) Callback(k string) tele.CallbackEndpoint {
return dlt.lt.Callback(k)
}
// Button wraps localized layout function Button using your default locale.
func (dlt *DefaultLayout) Button(k string, args ...interface{}) *tele.Btn {
return dlt.lt.ButtonLocale(dlt.locale, k, args...)
}
// Markup wraps localized layout function Markup using your default locale.
func (dlt *DefaultLayout) Markup(k string, args ...interface{}) *tele.ReplyMarkup {
return dlt.lt.MarkupLocale(dlt.locale, k, args...)
}
// Result wraps localized layout function Result using your default locale.
func (dlt *DefaultLayout) Result(k string, args ...interface{}) tele.Result {
return dlt.lt.ResultLocale(dlt.locale, k, args...)
}

76
layout/example.yml Normal file
View File

@ -0,0 +1,76 @@
settings:
token_env: TOKEN
parse_mode: html
long_poller: {}
commands:
/start: Start the bot
/help: How to use the bot
config:
str: string
num: 123
strs:
- abc
- def
nums:
- 123
- 456
obj: &obj
dur: 10m
arr:
- <<: *obj
- <<: *obj
buttons:
# Shortened reply buttons
help: Help
settings: Settings
# Extended reply button
contact:
text: Send a contact
request_contact: true
# Inline button
stop:
unique: stop
text: Stop
data: '{{.}}'
# Callback data
pay:
unique: pay
text: Pay
data:
- '{{ .UserID }}'
- '{{ .Amount }}'
- '{{ .Currency }}'
web_app:
text: This is a web app
web_app:
url: https://google.com
markups:
reply_shortened:
- [ help ]
- [ settings ]
reply_extended:
keyboard:
- [ contact ]
one_time_keyboard: true
inline:
- [ stop ]
web_app:
- [ web_app ]
results:
article:
type: article
id: '{{ .ID }}'
title: '{{ .Title }}'
description: '{{ .Description }}'
thumb_url: '{{ .PreviewURL }}'
message_text: '{{ text `article_message` }}'

579
layout/layout.go Normal file
View File

@ -0,0 +1,579 @@
package layout
import (
"bytes"
"encoding/json"
"io/ioutil"
"log"
"strings"
"sync"
"text/template"
tele "git.belvedersky.ru/common/telebot"
"github.com/goccy/go-yaml"
)
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 struct {
tele.Btn `yaml:",inline"`
Data interface{} `yaml:"data"`
IsReply bool `yaml:"reply"`
}
// 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
tele.ResultBase `yaml:",inline"`
Content ResultContent `yaml:"content"`
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 &lt, yaml.Unmarshal(data, &lt)
}
// 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
}
// CommandsLocale returns a list of telebot commands and localized description, which can be
// used in b.SetCommands later.
//
// Example of bot.yml:
//
// commands:
// /start: '{{ text "cmdStart" }}'
//
// en.yml:
//
// cmdStart: Start the bot
//
// ru.yml:
//
// cmdStart: Запуск бота
//
// Usage:
//
// b.SetCommands(lt.CommandsLocale("en"), "en")
// b.SetCommands(lt.CommandsLocale("ru"), "ru")
func (lt *Layout) CommandsLocale(locale string, args ...interface{}) (cmds []tele.Command) {
var arg interface{}
if len(args) > 0 {
arg = args[0]
}
for k, v := range lt.commands {
tmpl, err := lt.template(template.New(k).Funcs(lt.funcs), locale).Parse(v)
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
}
cmds = append(cmds, tele.Command{
Text: strings.TrimLeft(k, "/"),
Description: buf.String(),
})
}
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).Funcs(lt.funcs), locale).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.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:
// article:
// 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 Result
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
}

125
layout/layout_test.go Normal file
View File

@ -0,0 +1,125 @@
package layout
import (
"os"
"testing"
"time"
tele "git.belvedersky.ru/common/telebot"
"github.com/stretchr/testify/assert"
)
func TestLayout(t *testing.T) {
os.Setenv("TOKEN", "TEST")
lt, err := New("example.yml")
if err != nil {
t.Fatal(err)
}
pref := lt.Settings()
assert.Equal(t, "TEST", pref.Token)
assert.Equal(t, "html", pref.ParseMode)
assert.Equal(t, &tele.LongPoller{}, pref.Poller)
assert.ElementsMatch(t, []tele.Command{{
Text: "start",
Description: "Start the bot",
}, {
Text: "help",
Description: "How to use the bot",
}}, lt.Commands())
assert.Equal(t, "string", lt.String("str"))
assert.Equal(t, 123, lt.Int("num"))
assert.Equal(t, int64(123), lt.Int64("num"))
assert.Equal(t, float64(123), lt.Float("num"))
assert.Equal(t, tele.ChatID(123), lt.ChatID("num"))
assert.Equal(t, []string{"abc", "def"}, lt.Strings("strs"))
assert.Equal(t, []int{123, 456}, lt.Ints("nums"))
assert.Equal(t, []int64{123, 456}, lt.Int64s("nums"))
assert.Equal(t, []float64{123, 456}, lt.Floats("nums"))
obj := lt.Get("obj")
assert.NotNil(t, obj)
const dur = 10 * time.Minute
assert.Equal(t, dur, obj.Duration("dur"))
assert.True(t, lt.Duration("obj.dur") == obj.Duration("dur"))
arr := lt.Slice("arr")
assert.Len(t, arr, 2)
for _, v := range arr {
assert.Equal(t, dur, v.Duration("dur"))
}
assert.Equal(t, &tele.Btn{
Unique: "pay",
Text: "Pay",
Data: "1|100.00|USD",
}, lt.ButtonLocale("en", "pay", struct {
UserID int
Amount string
Currency string
}{
UserID: 1,
Amount: "100.00",
Currency: "USD",
}))
assert.Equal(t, &tele.ReplyMarkup{
ReplyKeyboard: [][]tele.ReplyButton{
{{Text: "Help"}},
{{Text: "Settings"}},
},
ResizeKeyboard: true,
}, lt.MarkupLocale("en", "reply_shortened"))
assert.Equal(t, &tele.ReplyMarkup{
ReplyKeyboard: [][]tele.ReplyButton{{{Text: "Send a contact", Contact: true}}},
ResizeKeyboard: true,
OneTimeKeyboard: true,
}, lt.MarkupLocale("en", "reply_extended"))
assert.Equal(t, &tele.ReplyMarkup{
InlineKeyboard: [][]tele.InlineButton{{
{
Unique: "stop",
Text: "Stop",
Data: "1",
},
}},
}, lt.MarkupLocale("en", "inline", 1))
assert.Equal(t, &tele.ReplyMarkup{
InlineKeyboard: [][]tele.InlineButton{{
{
Text: "This is a web app",
WebApp: &tele.WebApp{URL: "https://google.com"},
},
}},
}, lt.MarkupLocale("en", "web_app"))
assert.Equal(t, &tele.ArticleResult{
ResultBase: tele.ResultBase{
ID: "1853",
Type: "article",
},
Title: "Some title",
Description: "Some description",
ThumbURL: "https://preview.picture",
Text: "This is an article.",
}, lt.ResultLocale("en", "article", struct {
ID int
Title string
Description string
PreviewURL string
}{
ID: 1853,
Title: "Some title",
Description: "Some description",
PreviewURL: "https://preview.picture",
}))
}

1
layout/locales/en.yml Normal file
View File

@ -0,0 +1 @@
article_message: This is an article.

50
layout/middleware.go Normal file
View File

@ -0,0 +1,50 @@
package layout
import (
tele "git.belvedersky.ru/common/telebot"
)
// LocaleFunc is the function used to fetch the locale of the recipient.
// Returned locale will be remembered and linked to the corresponding context.
type LocaleFunc func(tele.Recipient) string
// Middleware builds a telebot middleware to make localization work.
//
// Usage:
//
// b.Use(lt.Middleware("en", func(r tele.Recipient) string {
// loc, _ := db.UserLocale(r.Recipient())
// return loc
// }))
func (lt *Layout) Middleware(defaultLocale string, localeFunc ...LocaleFunc) tele.MiddlewareFunc {
var f LocaleFunc
if len(localeFunc) > 0 {
f = localeFunc[0]
}
return func(next tele.HandlerFunc) tele.HandlerFunc {
return func(c tele.Context) error {
locale := defaultLocale
if f != nil {
if l := f(c.Sender()); l != "" {
locale = l
}
}
lt.SetLocale(c, locale)
defer func() {
lt.mu.Lock()
delete(lt.ctxs, c)
lt.mu.Unlock()
}()
return next(c)
}
}
}
// Middleware wraps ordinary layout middleware with your default locale.
func (dlt *DefaultLayout) Middleware() tele.MiddlewareFunc {
return dlt.lt.Middleware(dlt.locale)
}

268
layout/parser.go Normal file
View File

@ -0,0 +1,268 @@
package layout
import (
"fmt"
"io/ioutil"
"os"
"path/filepath"
"strings"
"text/template"
tele "git.belvedersky.ru/common/telebot"
"github.com/goccy/go-yaml"
"github.com/spf13/viper"
)
type Settings struct {
URL string
Token string
Updates int
LocalesDir string `yaml:"locales_dir"`
TokenEnv string `yaml:"token_env"`
ParseMode string `yaml:"parse_mode"`
Webhook *tele.Webhook `yaml:"webhook"`
LongPoller *tele.LongPoller `yaml:"long_poller"`
}
func (lt *Layout) UnmarshalYAML(data []byte) error {
var aux struct {
Settings *Settings
Config map[string]interface{}
Commands map[string]string
Buttons yaml.MapSlice
Markups yaml.MapSlice
Results yaml.MapSlice
Locales map[string]map[string]string
}
if err := yaml.Unmarshal(data, &aux); err != nil {
return err
}
v := viper.New()
if err := v.MergeConfigMap(aux.Config); err != nil {
return err
}
lt.Config = Config{v: v}
lt.commands = aux.Commands
if pref := aux.Settings; pref != nil {
lt.pref = &tele.Settings{
URL: pref.URL,
Token: pref.Token,
Updates: pref.Updates,
ParseMode: pref.ParseMode,
}
if pref.TokenEnv != "" {
lt.pref.Token = os.Getenv(pref.TokenEnv)
}
if pref.Webhook != nil {
lt.pref.Poller = pref.Webhook
} else if pref.LongPoller != nil {
lt.pref.Poller = pref.LongPoller
}
}
lt.buttons = make(map[string]Button, len(aux.Buttons))
for _, item := range aux.Buttons {
k, v := item.Key.(string), item.Value
// 1. Shortened reply button
if v, ok := v.(string); ok {
btn := tele.Btn{Text: v}
lt.buttons[k] = Button{Btn: btn}
continue
}
// 2. Extended reply or inline button
data, err := yaml.MarshalWithOptions(v, yaml.JSON())
if err != nil {
return err
}
var btn Button
if err := yaml.Unmarshal(data, &btn); err != nil {
return err
}
if !btn.IsReply && btn.Data != nil {
if a, ok := btn.Data.([]interface{}); ok {
s := make([]string, len(a))
for i, v := range a {
s[i] = fmt.Sprint(v)
}
btn.Btn.Data = strings.Join(s, "|")
} else if s, ok := btn.Data.(string); ok {
btn.Btn.Data = s
} else {
return fmt.Errorf("telebot/layout: invalid callback_data for %s button", k)
}
}
lt.buttons[k] = btn
}
lt.markups = make(map[string]Markup, len(aux.Markups))
for _, item := range aux.Markups {
k, v := item.Key.(string), item.Value
data, err := yaml.Marshal(v)
if err != nil {
return err
}
var shortenedMarkup [][]string
if yaml.Unmarshal(data, &shortenedMarkup) == nil {
// 1. Shortened reply or inline markup
kb := make([][]Button, len(shortenedMarkup))
for i, btns := range shortenedMarkup {
row := make([]Button, len(btns))
for j, btn := range btns {
b, ok := lt.buttons[btn]
if !ok {
return fmt.Errorf("telebot/layout: no %s button for %s markup", btn, k)
}
row[j] = b
}
kb[i] = row
}
data, err := yaml.Marshal(kb)
if err != nil {
return err
}
tmpl, err := template.New(k).Funcs(lt.funcs).Parse(string(data))
if err != nil {
return err
}
markup := Markup{keyboard: tmpl}
for _, row := range kb {
for _, btn := range row {
inline := btn.URL != "" ||
btn.Unique != "" ||
btn.InlineQuery != "" ||
btn.InlineQueryChat != "" ||
btn.Login != nil ||
btn.WebApp != nil
inline = !btn.IsReply && inline
if markup.inline == nil {
markup.inline = &inline
} else if *markup.inline != inline {
return fmt.Errorf("telebot/layout: mixed reply and inline buttons in %s markup", k)
}
}
}
lt.markups[k] = markup
} else {
// 2. Extended reply markup
var markup struct {
Markup `yaml:",inline"`
Keyboard [][]string `yaml:"keyboard"`
}
if err := yaml.Unmarshal(data, &markup); err != nil {
return err
}
kb := make([][]tele.ReplyButton, len(markup.Keyboard))
for i, btns := range markup.Keyboard {
row := make([]tele.ReplyButton, len(btns))
for j, btn := range btns {
row[j] = *lt.buttons[btn].Reply()
}
kb[i] = row
}
data, err := yaml.Marshal(kb)
if err != nil {
return err
}
tmpl, err := template.New(k).Funcs(lt.funcs).Parse(string(data))
if err != nil {
return err
}
markup.inline = new(bool)
markup.Markup.keyboard = tmpl
lt.markups[k] = markup.Markup
}
}
lt.results = make(map[string]Result, len(aux.Results))
for _, item := range aux.Results {
k, v := item.Key.(string), item.Value
data, err := yaml.Marshal(v)
if err != nil {
return err
}
tmpl, err := template.New(k).Funcs(lt.funcs).Parse(string(data))
if err != nil {
return err
}
var result Result
if err := yaml.Unmarshal(data, &result); err != nil {
return err
}
result.result = tmpl
lt.results[k] = result
}
if aux.Locales == nil {
if aux.Settings.LocalesDir == "" {
aux.Settings.LocalesDir = "locales"
}
return lt.parseLocales(aux.Settings.LocalesDir)
}
return nil
}
func (lt *Layout) parseLocales(dir string) error {
lt.locales = make(map[string]*template.Template)
return filepath.Walk(dir, func(path string, fi os.FileInfo, _ error) error {
if fi == nil || fi.IsDir() {
return nil
}
data, err := ioutil.ReadFile(path)
if err != nil {
return err
}
var texts map[string]string
if err := yaml.Unmarshal(data, &texts); err != nil {
return err
}
name := fi.Name()
name = strings.TrimSuffix(name, filepath.Ext(name))
tmpl := template.New(name).Funcs(lt.funcs)
for key, text := range texts {
_, err = tmpl.New(key).Parse(strings.Trim(text, "\r\n"))
if err != nil {
return err
}
}
lt.locales[name] = tmpl
return nil
})
}