l10n and bot command auto generation; v0.6.0

This commit is contained in:
2026-02-18 11:39:20 +03:00
parent bb51a0ecb1
commit b2bda02c0f
11 changed files with 182 additions and 102 deletions

16
bot.go
View File

@@ -41,7 +41,7 @@ func LoadSettingsFromEnv() *BotSettings {
func LoadPrefixesFromEnv() []string {
prefixesS, exists := os.LookupEnv("PREFIXES")
if !exists {
return []string{"!"}
return []string{"/"}
}
return strings.Split(prefixesS, ";")
}
@@ -61,6 +61,7 @@ type Bot struct {
dbContext *DatabaseContext
api *tgapi.API
l10n L10n
dbWriterRequested extypes.Slice[*slog.Logger]
@@ -76,7 +77,7 @@ func NewBot(settings *BotSettings) *Bot {
updateOffset: 0, plugins: make([]Plugin, 0), debug: settings.Debug, errorTemplate: "%s",
prefixes: settings.Prefixes, updateTypes: make([]tgapi.UpdateType, 0), runners: make([]Runner, 0),
updateQueue: updateQueue, api: api, dbWriterRequested: make([]*slog.Logger, 0),
token: settings.Token,
token: settings.Token, l10n: L10n{},
}
bot.dbWriterRequested = bot.dbWriterRequested.Push(api.Logger)
@@ -182,9 +183,9 @@ func (b *Bot) Debug(debug bool) *Bot {
b.debug = debug
return b
}
func (b *Bot) AddPlugins(plugin ...Plugin) *Bot {
b.plugins = append(b.plugins, plugin...)
func (b *Bot) AddPlugins(plugin ...*Plugin) *Bot {
for _, p := range plugin {
b.plugins = append(b.plugins, *p)
b.logger.Debugln(fmt.Sprintf("plugins with name \"%s\" registered", p.Name))
}
return b
@@ -211,6 +212,13 @@ func (b *Bot) AddRunner(runner Runner) *Bot {
b.logger.Debugln(fmt.Sprintf("runner with name \"%s\" registered", runner.Name))
return b
}
func (b *Bot) AddL10n(l L10n) *Bot {
b.l10n = l
return b
}
func (b *Bot) L10n(lang, key string) string {
return b.l10n.Translate(lang, key)
}
func (b *Bot) Logger() *slog.Logger {
return b.logger
}

View File

@@ -1,26 +1,59 @@
package laniakea
import "git.nix13.pw/scuroneko/laniakea/tgapi"
import (
"fmt"
"strings"
"git.nix13.pw/scuroneko/laniakea/tgapi"
)
func generateBotCommand(cmd Command) tgapi.BotCommand {
return tgapi.BotCommand{
Command: cmd.command, Description: cmd.command,
desc := cmd.command
if len(cmd.description) > 0 {
desc = cmd.description
}
var descArgs []string
for _, a := range cmd.args {
if a.required {
descArgs = append(descArgs, fmt.Sprintf("%s", a.text))
} else {
descArgs = append(descArgs, fmt.Sprintf("[%s]", a.text))
}
}
desc = fmt.Sprintf("%s. Usage: /%s %s", desc, cmd.command, strings.Join(descArgs, " "))
return tgapi.BotCommand{Command: cmd.command, Description: desc}
}
func generateBotCommandForPlugin(pl Plugin) []tgapi.BotCommand {
cmds := make([]tgapi.BotCommand, 0)
commands := make([]tgapi.BotCommand, 0)
for _, cmd := range pl.Commands {
cmds = append(cmds, generateBotCommand(cmd))
commands = append(commands, generateBotCommand(cmd))
}
return cmds
return commands
}
func (b *Bot) AutoGenerateCommands() error {
_, err := b.api.DeleteMyCommands(tgapi.DeleteMyCommandsP{})
if err != nil {
return err
}
commands := make([]tgapi.BotCommand, 0)
for _, pl := range b.plugins {
commands = append(commands, generateBotCommandForPlugin(pl)...)
}
_, err := b.api.SetMyCommands(tgapi.SetMyCommandsP{Commands: commands})
privateChatsScope := &tgapi.BotCommandScope{Type: tgapi.BotCommandScopePrivateType}
groupChatsScope := &tgapi.BotCommandScope{Type: tgapi.BotCommandScopeGroupType}
chatAdminsScope := &tgapi.BotCommandScope{Type: tgapi.BotCommandScopeAllChatAdministratorsType}
_, err = b.api.SetMyCommands(tgapi.SetMyCommandsP{Commands: commands, Scope: privateChatsScope})
if err != nil {
return err
}
_, err = b.api.SetMyCommands(tgapi.SetMyCommandsP{Commands: commands, Scope: groupChatsScope})
if err != nil {
return err
}
_, err = b.api.SetMyCommands(tgapi.SetMyCommandsP{Commands: commands, Scope: chatAdminsScope})
return err
}

5
go.mod
View File

@@ -1,11 +1,11 @@
module git.nix13.pw/scuroneko/laniakea
go 1.25
go 1.26
require (
git.nix13.pw/scuroneko/extypes v1.2.0
git.nix13.pw/scuroneko/slog v1.0.2
github.com/redis/go-redis/v9 v9.17.3
github.com/redis/go-redis/v9 v9.18.0
github.com/vinovest/sqlx v1.7.1
go.mongodb.org/mongo-driver/v2 v2.5.0
)
@@ -23,6 +23,7 @@ require (
github.com/xdg-go/scram v1.2.0 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
go.uber.org/atomic v1.11.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect

10
go.sum
View File

@@ -22,6 +22,8 @@ github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
@@ -36,8 +38,8 @@ github.com/muir/sqltoken v0.3.0 h1:3xbcqr80f3IA4OlwkOpdIHC4DTu6gsi1TwMqgYL4Dpg=
github.com/muir/sqltoken v0.3.0/go.mod h1:+OSmbGI22QcVZ6DCzlHT8EAzEq/mqtqedtPP91Le+3A=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/redis/go-redis/v9 v9.17.3 h1:fN29NdNrE17KttK5Ndf20buqfDZwGNgoUr9qjl1DQx4=
github.com/redis/go-redis/v9 v9.17.3/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/vinovest/sqlx v1.7.1 h1:kdq4v0N9kRLpytWGSWOw4aulOGdQPmIoMR6Y+cTBxow=
@@ -51,8 +53,12 @@ github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gi
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=

View File

@@ -46,7 +46,6 @@ func (b *Bot) handleMessage(update *tgapi.Update, ctx *MsgContext) {
text = strings.TrimSpace(text[len(prefix):])
for _, plugin := range b.plugins {
// Check every command
for cmd := range plugin.Commands {
if !strings.HasPrefix(text, cmd) {
continue
@@ -60,17 +59,22 @@ func (b *Bot) handleMessage(update *tgapi.Update, ctx *MsgContext) {
break
}
}
if !isValid {
continue
}
ctx.Text = strings.TrimSpace(text[len(cmd):])
if ctx.Text == "" {
ctx.Args = []string{}
} else {
ctx.Args = strings.Split(ctx.Text, " ")
}
if !plugin.executeMiddlewares(ctx, b.dbContext) {
return
}
go plugin.Execute(cmd, ctx, b.dbContext)
go plugin.executeCmd(cmd, ctx, b.dbContext)
return
}
}
@@ -100,7 +104,7 @@ func (b *Bot) handleCallback(update *tgapi.Update, ctx *MsgContext) {
if !plugin.executeMiddlewares(ctx, b.dbContext) {
return
}
go plugin.ExecutePayload(data.Command, ctx, b.dbContext)
go plugin.executePayload(data.Command, ctx, b.dbContext)
return
}
}

27
l10n.go Normal file
View File

@@ -0,0 +1,27 @@
package laniakea
// DictEntry {key:{ru:123,en:123}}
type DictEntry map[string]string
type L10n struct {
entries map[string]DictEntry
fallbackLang string
}
func NewL10n(fallbackLanguage string) *L10n {
return &L10n{make(map[string]DictEntry), fallbackLanguage}
}
func (l *L10n) AddDictEntry(key string, value DictEntry) *L10n {
l.entries[key] = value
return l
}
func (l *L10n) GetFallbackLanguage() string {
return l.fallbackLang
}
func (l *L10n) Translate(lang, key string) string {
s, ok := l.entries[key]
if !ok {
return key
}
return s[lang]
}

View File

@@ -212,3 +212,11 @@ func (ctx *MsgContext) error(err error) {
func (ctx *MsgContext) Error(err error) {
ctx.error(err)
}
func (ctx *MsgContext) Translate(key string) string {
if ctx.From == nil {
return key
}
lang := Val(ctx.From.LanguageCode, ctx.Bot.l10n.GetFallbackLanguage())
return ctx.Bot.L10n(lang, key)
}

View File

@@ -1,13 +1,12 @@
package laniakea
import (
"errors"
"regexp"
"git.nix13.pw/scuroneko/extypes"
)
type CommandExecutor func(ctx *MsgContext, dbContext *DatabaseContext)
const (
CommandValueStringType CommandValueType = "string"
CommandValueIntType CommandValueType = "int"
@@ -17,7 +16,12 @@ const (
var (
CommandRegexInt = regexp.MustCompile("\\d+")
CommandRegexString = regexp.MustCompile("\\.+")
CommandRegexString = regexp.MustCompile(".+")
)
var (
ErrCmdArgCountMismatch = errors.New("command arg count mismatch")
ErrCmdArgRegexpMismatch = errors.New("command arg regexp mismatch")
)
type CommandValueType string
@@ -25,69 +29,102 @@ type CommandArg struct {
valueType CommandValueType
text string
regex *regexp.Regexp
required bool
}
func NewCommandArg(text string, valueType CommandValueType) CommandArg {
func NewCommandArg(text string, valueType CommandValueType) *CommandArg {
regex := CommandRegexString
switch valueType {
case CommandValueIntType:
regex = CommandRegexInt
}
return CommandArg{valueType, text, regex}
return &CommandArg{valueType, text, regex, false}
}
func (c *CommandArg) SetRequired() *CommandArg {
c.required = true
return c
}
type CommandExecutor func(ctx *MsgContext, dbContext *DatabaseContext)
type Command struct {
command string
description string
exec CommandExecutor
args []CommandArg
middlewares []Middleware
args extypes.Slice[CommandArg]
middlewares extypes.Slice[Middleware]
}
func NewCommand(exec CommandExecutor, command string, args ...CommandArg) *Command {
return &Command{command, exec, args, make([]Middleware, 0)}
return &Command{command, "", exec, args, make(extypes.Slice[Middleware], 0)}
}
func (c *Command) Use(m Middleware) *Command {
c.middlewares = c.middlewares.Push(m)
return c
}
func (c *Command) SetDescription(desc string) *Command {
c.description = desc
return c
}
func (c *Command) validateArgs(args []string) error {
cmdArgs := c.args.Filter(func(e CommandArg) bool { return !e.required })
if len(args) < cmdArgs.Len() {
return ErrCmdArgCountMismatch
}
for i, arg := range args {
if i >= c.args.Len() {
break
}
cmdArg := c.args.Get(i)
if cmdArg.regex == nil {
continue
}
if !cmdArg.regex.MatchString(arg) {
return ErrCmdArgRegexpMismatch
}
}
return nil
}
type Plugin struct {
Name string
Commands map[string]Command
Payloads map[string]Command
Middlewares extypes.Slice[PluginMiddleware]
Middlewares extypes.Slice[Middleware]
}
func NewPlugin(name string) *Plugin {
return &Plugin{
Name: name,
Commands: map[string]Command{},
Payloads: map[string]Command{},
Middlewares: extypes.Slice[PluginMiddleware]{},
name, map[string]Command{},
map[string]Command{}, extypes.Slice[Middleware]{},
}
}
func (p *Plugin) AddCommand(command Command) *Plugin {
p.Commands[command.command] = command
func (p *Plugin) AddCommand(command *Command) *Plugin {
p.Commands[command.command] = *command
return p
}
func (p *Plugin) AddPayload(command Command) *Plugin {
p.Payloads[command.command] = command
func (p *Plugin) AddPayload(command *Command) *Plugin {
p.Payloads[command.command] = *command
return p
}
func (p *Plugin) AddMiddleware(middleware PluginMiddleware) *Plugin {
func (p *Plugin) AddMiddleware(middleware Middleware) *Plugin {
p.Middlewares = p.Middlewares.Push(middleware)
return p
}
func (p *Plugin) Execute(cmd string, ctx *MsgContext, dbContext *DatabaseContext) {
func (p *Plugin) executeCmd(cmd string, ctx *MsgContext, dbContext *DatabaseContext) {
command := p.Commands[cmd]
if !command.validateArgs(ctx.Args) {
if err := command.validateArgs(ctx.Args); err != nil {
ctx.error(err)
return
}
command.exec(ctx, dbContext)
}
func (p *Plugin) ExecutePayload(payload string, ctx *MsgContext, dbContext *DatabaseContext) {
func (p *Plugin) executePayload(payload string, ctx *MsgContext, dbContext *DatabaseContext) {
pl := p.Payloads[payload]
if !pl.validateArgs(ctx.Args) {
if err := pl.validateArgs(ctx.Args); err != nil {
ctx.error(err)
return
}
pl.exec(ctx, dbContext)
@@ -100,30 +137,19 @@ func (p *Plugin) executeMiddlewares(ctx *MsgContext, db *DatabaseContext) bool {
}
return true
}
func (c *Command) validateArgs(args []string) bool {
if len(args) != len(c.args) {
return false
}
for i, arg := range c.args {
if arg.regex == nil {
continue
}
if !arg.regex.MatchString(args[i]) {
return false
}
}
return true
}
type MiddlewareExecutor func(ctx *MsgContext, db *DatabaseContext) bool
// Middleware
// When async, returned value ignored
type Middleware struct {
name string
exec CommandExecutor
executor MiddlewareExecutor
order int
async bool
}
func NewMiddleware(name string, executor CommandExecutor) *Middleware {
func NewMiddleware(name string, executor MiddlewareExecutor) *Middleware {
return &Middleware{name, executor, 0, false}
}
func (m *Middleware) SetOrder(order int) *Middleware {
@@ -134,40 +160,7 @@ func (m *Middleware) SetAsync(async bool) *Middleware {
m.async = async
return m
}
func (m *Middleware) Execute(ctx *MsgContext, db *DatabaseContext) {
if m.async {
go m.exec(ctx, db)
} else {
m.Execute(ctx, db)
}
}
type PluginMiddlewareExecutor func(ctx *MsgContext, db *DatabaseContext) bool
// PluginMiddleware
// When async, returned value ignored
type PluginMiddleware struct {
executor PluginMiddlewareExecutor
order int
async bool
}
func NewPluginMiddleware(executor PluginMiddlewareExecutor) *PluginMiddleware {
return &PluginMiddleware{
executor: executor,
order: 0,
async: false,
}
}
func (m *PluginMiddleware) SetOrder(order int) *PluginMiddleware {
m.order = order
return m
}
func (m *PluginMiddleware) SetAsync(async bool) *PluginMiddleware {
m.async = async
return m
}
func (m *PluginMiddleware) Execute(ctx *MsgContext, db *DatabaseContext) bool {
func (m *Middleware) Execute(ctx *MsgContext, db *DatabaseContext) bool {
if m.async {
go m.executor(ctx, db)
return true

View File

@@ -67,9 +67,6 @@ func (r TelegramRequest[R, P]) DoWithContext(ctx context.Context, api *API) (R,
return zero, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return zero, fmt.Errorf("unexpected status code: %d", res.StatusCode)
}
reader := io.LimitReader(res.Body, 10<<20)
data, err = io.ReadAll(reader)
@@ -77,6 +74,9 @@ func (r TelegramRequest[R, P]) DoWithContext(ctx context.Context, api *API) (R,
return zero, err
}
api.Logger.Debugln("RES", r.method, string(data))
if res.StatusCode != http.StatusOK {
return zero, fmt.Errorf("unexpected status code: %d", res.StatusCode)
}
var resp ApiResponse[R]
err = json.Unmarshal(data, &resp)

View File

@@ -9,7 +9,7 @@ type BotCommandScopeType string
const (
BotCommandScopeDefaultType BotCommandScopeType = "default"
BotCommandScopePrivateType BotCommandScopeType = "all_private_chats"
BotCommandScopeGroupType BotCommandScopeType = "all_groups_chats"
BotCommandScopeGroupType BotCommandScopeType = "all_group_chats"
BotCommandScopeAllChatAdministratorsType BotCommandScopeType = "all_chat_administrators"
BotCommandScopeChatType BotCommandScopeType = "chat"
BotCommandScopeChatAdministratorsType BotCommandScopeType = "chat_administrators"

View File

@@ -1,8 +1,8 @@
package utils
const (
VersionString = "0.5.0"
VersionString = "0.6.0"
VersionMajor = 0
VersionMinor = 5
VersionMinor = 6
VersionPatch = 0
)