diff --git a/bot.go b/bot.go index b16b8fe..86bbfc8 100644 --- a/bot.go +++ b/bot.go @@ -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 } diff --git a/cmd_generator.go b/cmd_generator.go index 9653f96..083b14b 100644 --- a/cmd_generator.go +++ b/cmd_generator.go @@ -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 } diff --git a/go.mod b/go.mod index d6912d1..bf03cd1 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 8340bd9..2368236 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/handler.go b/handler.go index 9d73ea1..b774770 100644 --- a/handler.go +++ b/handler.go @@ -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):]) - ctx.Args = strings.Split(ctx.Text, " ") + 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 } } diff --git a/l10n.go b/l10n.go new file mode 100644 index 0000000..a74f32f --- /dev/null +++ b/l10n.go @@ -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] +} diff --git a/msg_context.go b/msg_context.go index 8ff7396..c20c249 100644 --- a/msg_context.go +++ b/msg_context.go @@ -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) +} diff --git a/plugins.go b/plugins.go index aa04a36..cecd014 100644 --- a/plugins.go +++ b/plugins.go @@ -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 - order int - async bool + name string + 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 diff --git a/tgapi/api.go b/tgapi/api.go index 8e7f2c9..320eee3 100644 --- a/tgapi/api.go +++ b/tgapi/api.go @@ -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) diff --git a/tgapi/bot_types.go b/tgapi/bot_types.go index d73c30c..a17eaf0 100644 --- a/tgapi/bot_types.go +++ b/tgapi/bot_types.go @@ -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" diff --git a/utils/version.go b/utils/version.go index adc8290..d1e9386 100644 --- a/utils/version.go +++ b/utils/version.go @@ -1,8 +1,8 @@ package utils const ( - VersionString = "0.5.0" + VersionString = "0.6.0" VersionMajor = 0 - VersionMinor = 5 + VersionMinor = 6 VersionPatch = 0 )