diff --git a/command_context.go b/command_context.go index 5e7371f..2808589 100644 --- a/command_context.go +++ b/command_context.go @@ -7,14 +7,22 @@ import ( "github.com/SevereCloud/vksdk/v3/api/params" "github.com/SevereCloud/vksdk/v3/events" "github.com/SevereCloud/vksdk/v3/object" + "txts.su/vkc/filters" ) // Контекст команды. Передается в каждый обработчик. type CommandContext[DEPS any] struct { - VK *api.VK - Message object.MessagesMessage - Arguments []string - RawEvent events.MessageNewObject + // Объект API VK. + VK *api.VK + // Сообщение, вызвавшее команду. + Message object.MessagesMessage + // Список аргументов команды. Если аргументы не удалось получить, срез будет пуст. + Arguments []string + // Результаты извлечений из фильтра команды (остаток строки, группы regex и т.д.). + FilterMatch filters.MatchResult + // Исходное событие message_new. + RawEvent events.MessageNewObject + // Объект с зависимостями всей системы команд. Dependency DEPS } diff --git a/command_handler.go b/command_handler.go index 56a9ef6..56c155c 100644 --- a/command_handler.go +++ b/command_handler.go @@ -1,5 +1,10 @@ package vkc +import ( + "txts.su/vkc/filters" + vkfilters "txts.su/vkc/filters/vk_filters" +) + // Функция обработчика команды. Получает контекст и возвращает ошибку или nil. type HandlerFunc[DEPS any] func(ctx CommandContext[DEPS]) error @@ -33,16 +38,41 @@ type HandlerAccessCheck[DEPS any] struct { // из дженерика в объекте команд; // впоследствии в контекст обработчика будет передано значение этого типа в поле Dependency */ // var HandleSomeCommand = CommandHandler[DepsType]{ -// Pattern: Text("some"), // шаблон "только строка `some`" +// Filter: filter.PrefixF("some") // шаблон "только строка `some`" // Help: CommandHelp{ /* помощь по команде */ }, // AccessCheck: &HandlerAccessCheck[DepsType]{ /* проверка доступа */ }, // Executor: func(ctx CommandContext[DepsType]) error { /* логика команды */ }, // } type CommandHandler[DEPS any] struct { - Pattern CommandPattern - Help CommandHelp + // Фильтр, совпадение с которым запустит команду + Filter filters.Filter + // Deprecated: будет удалено в v2. Используйте [CommandHandler.Filter]. + Pattern CommandPattern + // Помощь по команде + Help CommandHelp + // Проверка на доступ к команде AccessCheck *HandlerAccessCheck[DEPS] - Executor HandlerFunc[DEPS] + // Исполнитель команды + Executor HandlerFunc[DEPS] +} + +// Метод для проверки совпадения контекста с фильтром команды. +func (handler *CommandHandler[T]) IsNotFiltered(ctx CommandContext[T], prefixRemainder string) (result bool, sideValues filters.MatchResult) { + input := map[filters.FieldDescriptor]any{ + vkfilters.MessageField: ctx.Message, + vkfilters.VkApiField: ctx.VK, + filters.TextField: prefixRemainder, + } + if !handler.Filter.Eval(input) { + return false, nil + } + // по умолчанию grfilt работает с "чистыми" фильтрами. + // а для работы команд необходимо еще и знать контекст срабатывания фильтра, + // поэтому в vkc используются фильтры с извлечениями. + if ex, ok := handler.Filter.(filters.Extractor); ok { + return true, ex.Extract(input) + } + return true, nil } // Метод для проверки доступности команды для пользователя. diff --git a/commands.go b/commands.go index 47b29ec..5904517 100644 --- a/commands.go +++ b/commands.go @@ -17,12 +17,19 @@ import ( "github.com/SevereCloud/vksdk/v3/api" "github.com/SevereCloud/vksdk/v3/events" "github.com/SevereCloud/vksdk/v3/longpoll-bot" + "txts.su/vkc/filters" ) func logDeprecationWarning(feature string) { log.Printf("обработчик %s скоро перестанет вызываться. См.: https://github.com/EgorBron/vkc/issues/1", feature) } +type CommandsOptions struct { // TODO: задокументировать в v1.3.0 + AllowPrefixless bool + stripSpaces bool + stripPunctuation bool +} + // Структура для управления командами. Содержит обработчики, префикс и зависимости. // Структуру можно типизировать дженериком для передачи зависимостей в обработчики команд, или передать any в дженерик, если это не требуется. // Для обработчиков команд опеределено поле Handlers, принимающее массив указателей на обработчики. Рекомендуется получать адрес обработчика прямо при передаче в массив, как здесь: @@ -42,7 +49,11 @@ func logDeprecationWarning(feature string) { type Commands[DEPS any] struct { cmdMutex sync.RWMutex + // Префикс для команд. Prefix PrefixMatcher + + Options CommandsOptions + // Структура для передачи зависимостей в обработчики команд. Если зависимости не требуются, можно указать any в дженерике. // // Deprecated: Начиная с v2 будет удалено. Рекомендуется перейти на [context.Context] (см. https://github.com/EgorBron/vkc/issues/2 для просмотра обсуждения). @@ -62,7 +73,7 @@ type Commands[DEPS any] struct { OnCommandError *func(ctx CommandContext[DEPS], err error) } -// Поиск команды по строке. Возвращает найденный обработчик и остаток строки (т.е. без названия команды). +// ППоиск команды по строке. Возвращает найденный обработчик и остаток строки (т.е. без названия команды). // // Поскольку срезы в Go являются упорядоченными, то при поиске приоритет будут иметь обработчики, расположенные ближе к началу среза. // @@ -75,6 +86,8 @@ type Commands[DEPS any] struct { // FindCommand("tag me", handlers), есть обработчики Text("tag") и Text("tag me") -> (обработчик для Text("tag"), остаток - "me") // // и так далее. +// +// Deprecated: будет удалено в v2. Функция больше не используется. func FindCommand[DEPS any](rawCmd string, commands []*CommandHandler[DEPS]) (*CommandHandler[DEPS], string) { for _, handler := range commands { if handler == nil { @@ -86,7 +99,7 @@ func FindCommand[DEPS any](rawCmd string, commands []*CommandHandler[DEPS]) (*Co for i := len(words); i > 0; i-- { candidate := strings.Join(words[:i], " ") - if handler.Pattern(candidate) { + if handler.Pattern != nil && handler.Pattern(candidate) { return handler, strings.Join(words[i:], " ") } } @@ -130,7 +143,10 @@ func (commands *Commands[any]) ProcessCommands(ctx context.Context, vk *api.VK, matched, rawCmd := commands.Prefix(text) if !matched { - return ErrNoPrefix + if !commands.Options.AllowPrefixless { + return ErrNoPrefix + } + rawCmd = text } cmdCtx := CommandContext[any]{ @@ -154,7 +170,20 @@ func (commands *Commands[any]) ProcessCommands(ctx context.Context, vk *api.VK, copy(handlers, commands.Handlers) commands.cmdMutex.RUnlock() - handler, remaining := FindCommand(rawCmd, handlers) + var handler *CommandHandler[any] + var matchResult filters.MatchResult + var legacyRemainder string + for _, h := range handlers { + if matched, match := h.IsNotFiltered(cmdCtx, rawCmd); matched { + handler = h + matchResult = match + break + } + } + if handler == nil { + handler, legacyRemainder = FindCommand(rawCmd, handlers) + } + if handler == nil { if commands.OnUnknownCommand != nil { logDeprecationWarning("OnUnknownCommand") @@ -163,7 +192,12 @@ func (commands *Commands[any]) ProcessCommands(ctx context.Context, vk *api.VK, return ErrCommandNotFound } - cmdCtx.Arguments = SplitArgs(remaining) + cmdCtx.FilterMatch = matchResult + if matchResult != nil { + cmdCtx.Arguments = ArgumentsFromMatch(matchResult) + } else { + cmdCtx.Arguments = SplitArgs(legacyRemainder) + } if !handler.IsAccessAvailable(cmdCtx) { if commands.OnNoPermissions != nil { diff --git a/filters/composite.go b/filters/composite.go new file mode 100644 index 0000000..7b93c0e --- /dev/null +++ b/filters/composite.go @@ -0,0 +1,53 @@ +package filters + +import "txts.su/grfilt" + +type CompositeFilter struct { + filters []Filter + negated bool +} + +func NewComposite(items ...Filter) *CompositeFilter { + return &CompositeFilter{filters: items} +} + +func (f *CompositeFilter) Not() Filter { + f.negated = !f.negated + return f +} + +func And(filters ...Filter) *CompositeFilter { + return NewComposite(filters...) +} + +func (f *CompositeFilter) And(otherFilters ...Filter) Filter { + return grfilt.CombineFilters(NewComposite, f, otherFilters) +} + +func (f *CompositeFilter) Or(otherFilters ...Filter) Filter { + return grfilt.CombineFilters(NewEither, f, otherFilters) +} + +func (cf *CompositeFilter) Eval(input map[FieldDescriptor]any) (result bool) { + result = true + for _, f := range cf.filters { + if !f.Eval(input) { + result = false + break + } + } + if cf.negated { + result = !result + } + return +} + +func (cf *CompositeFilter) Extract(input map[FieldDescriptor]any) MatchResult { + result := make(MatchResult) + for _, f := range cf.filters { + if ex, ok := f.(Extractor); ok { + mergeResults(result, ex.Extract(input)) + } + } + return result +} diff --git a/filters/either.go b/filters/either.go new file mode 100644 index 0000000..f0d2e0b --- /dev/null +++ b/filters/either.go @@ -0,0 +1,56 @@ +package filters + +import "txts.su/grfilt" + +// EitherFilter — композиция фильтров по логическому ИЛИ. +type EitherFilter struct { + filters []Filter + negated bool +} + +func NewEither(filters ...Filter) *EitherFilter { + return &EitherFilter{filters: filters} +} + +// Or создаёт фильтр с логическим ИЛИ. +func Or(filters ...Filter) *EitherFilter { + return NewEither(filters...) +} + +func (f *EitherFilter) And(otherFilters ...Filter) Filter { + return grfilt.CombineFilters(NewComposite, f, otherFilters) +} + +func (f *EitherFilter) Or(otherFilters ...Filter) Filter { + return grfilt.CombineFilters(NewEither, f, otherFilters) +} + +func (f *EitherFilter) Not() Filter { + return leafFilter{inner: grfilt.Not(f)} +} + +func (cf *EitherFilter) Eval(input map[FieldDescriptor]any) (result bool) { + for _, f := range cf.filters { + if !f.Eval(input) { + result = true + break + } + } + if cf.negated { + result = !result + } + return +} + +func (f *EitherFilter) Extract(input map[FieldDescriptor]any) MatchResult { + result := make(MatchResult) + for _, f := range f.filters { + if !f.Eval(input) { + continue + } + if ex, ok := f.(Extractor); ok { + mergeResults(result, ex.Extract(input)) + } + } + return result +} diff --git a/filters/extractable.go b/filters/extractable.go new file mode 100644 index 0000000..b5cb7c1 --- /dev/null +++ b/filters/extractable.go @@ -0,0 +1,45 @@ +package filters + +import "txts.su/grfilt" + +type extractableFilter[T any] struct { + field FieldDescriptor + eval func(T) bool + extract func(T) MatchResult +} + +func (f *extractableFilter[T]) Eval(input map[FieldDescriptor]any) bool { + value, ok := input[f.field].(T) + if !ok { + return false + } + return f.eval(value) +} + +func (f *extractableFilter[T]) Extract(input map[FieldDescriptor]any) MatchResult { + value, ok := input[f.field].(T) + if !ok { + return nil + } + return f.extract(value) +} + +func (f *extractableFilter[T]) Not() Filter { + return leafFilter{inner: grfilt.Not(f)} +} + +func (f *extractableFilter[T]) And(otherFilters ...Filter) Filter { + return And(append([]Filter{f}, otherFilters...)...) +} + +func (f *extractableFilter[T]) Or(otherFilters ...Filter) Filter { + return Or(append([]Filter{f}, otherFilters...)...) +} + +func NewExtractableFilter[T any](field FieldDescriptor, eval func(T) bool, extract func(T) MatchResult) Filter { + return &extractableFilter[T]{ + field: field, + eval: eval, + extract: extract, + } +} diff --git a/filters/filters.go b/filters/filters.go new file mode 100644 index 0000000..9c4357b --- /dev/null +++ b/filters/filters.go @@ -0,0 +1,12 @@ +package filters + +import "txts.su/grfilt" + +type Filter = grfilt.Filter +type FieldDescriptor = grfilt.FieldDescriptor + +type MatchResult map[string]any + +type Extractor interface { + Extract(input map[FieldDescriptor]any) MatchResult +} diff --git a/filters/leaf.go b/filters/leaf.go new file mode 100644 index 0000000..c290871 --- /dev/null +++ b/filters/leaf.go @@ -0,0 +1,28 @@ +package filters + +import "txts.su/grfilt" + +type leafFilter struct { + inner Filter +} + +func (f leafFilter) Eval(input map[FieldDescriptor]any) bool { + return f.inner.Eval(input) +} + +func (f leafFilter) Not() Filter { + return leafFilter{inner: grfilt.Not(f.inner)} +} + +func (f leafFilter) And(otherFilters ...Filter) Filter { + return And(append([]Filter{f}, otherFilters...)...) +} + +func (f leafFilter) Or(otherFilters ...Filter) Filter { + return Or(append([]Filter{f}, otherFilters...)...) +} + +// NewFilter создаёт листовой фильтр по полю и предикату (только bool, без Extractor). +func NewFilter[T any](fieldName FieldDescriptor, fn func(value T) bool) Filter { + return leafFilter{inner: grfilt.NewFilter(fieldName, fn)} +} diff --git a/filters/list.go b/filters/list.go new file mode 100644 index 0000000..9bb64c9 --- /dev/null +++ b/filters/list.go @@ -0,0 +1,21 @@ +package filters + +import "slices" + +// Создает новый фильтр, срабатывающий только при совпадении с любым элементом из среза. +// Элементы, стоящие в срезе первее, имеют приоритет при поиске. +// +// Требует указания поля фильтра. +// +// Возвращаемые побочные значения: +// - `list_filter_match_index` - индекс совпавшего элемента или -1 при отсутствии совпадений. +func InList[T comparable](field FieldDescriptor, list []T) Filter { + return NewExtractableFilter(field, + func(value T) bool { + return slices.Index(list, value) >= 0 + }, + func(value T) MatchResult { + return MatchResult{"list_filter_match_index": slices.Index(list, value)} + }, + ) +} diff --git a/filters/text.go b/filters/text.go new file mode 100644 index 0000000..6652e77 --- /dev/null +++ b/filters/text.go @@ -0,0 +1,106 @@ +package filters + +import ( + "regexp" + "strings" +) + +const ( + TextField FieldDescriptor = "text" +) + +// Создает новый фильтр, срабатывающий только при полном совпадении строки. +// +// Требует поле фильтра: [TextField] +func Text(match string) Filter { + return NewFilter(TextField, func(value string) bool { + return value == match + }) +} + +// Создает новый фильтр, срабатывающий только при совпадении с регулярным выражением. +// +// Требует поле фильтра: [TextField] +// +// Возвращаемые побочные значения: +// - `command_regex_filter_groups` - группы захвата в регулярном выражении. +func TextRegexCompiled(re *regexp.Regexp) Filter { + return NewExtractableFilter(TextField, + func(value string) bool { + return re.FindStringSubmatch(value) != nil + }, + func(value string) MatchResult { + matches := re.FindStringSubmatch(value) + groups := []string{} + if len(matches) > 1 { + groups = append(groups, matches[1:]...) + } + return MatchResult{"command_regex_filter_groups": groups} + }, + ) +} + +// Создает новый фильтр, срабатывающий только при совпадении с регулярным выражением в виде строки. +// При вычислении может вызвать панику, если регулярное выражение сформировано неверно. +// +// Требует поле фильтра: [TextField] +// +// Возвращаемые побочные значения: +// - `command_regex_filter_groups` - группы захвата в регулярном выражении. +func TextRegex(pattern string) Filter { + re := regexp.MustCompile(pattern) + return TextRegexCompiled(re) +} + +// Создает новый фильтр, срабатывающий только при совпадении по префиксу. +// +// Требует поле фильтра: [TextField] +// +// Возвращаемые побочные значения: +// - `prefix_filter_remainder` - остаток после исключения префикса. +func TextPrefix(prefix string) Filter { + return NewExtractableFilter(TextField, + func(value string) bool { + return strings.HasPrefix(value, prefix) + }, + func(value string) MatchResult { + return MatchResult{"prefix_filter_remainder": value[len(prefix):]} + }, + ) +} + +func matchingTextPrefix(value string, prefixes []string) (prefix string, matched bool) { + for _, p := range prefixes { + if strings.HasPrefix(value, p) { + return p, true + } + } + return "", false +} + +// Создает новый фильтр, срабатывающий только при совпадении с любым префиксом из среза. +// Префиксы, находящиеся в срезе первее, при поиске имеют приоритет над префиксами после них. +// +// Требует поле фильтра: [TextField] +// +// Возвращаемые побочные значения: +// - `prefix_filter_match` - совпавший префикс; +// - `prefix_filter_remainder` - остаток после исключения префикса. +func TextPrefixListOf(prefixList []string) Filter { + return NewExtractableFilter(TextField, + func(value string) bool { + _, matched := matchingTextPrefix(value, prefixList) + return matched + }, + func(value string) MatchResult { + prefix, matched := matchingTextPrefix(value, prefixList) + if !matched { + return nil + } + return MatchResult{ + "prefix_filter_match": prefix, + "prefix_filter_remainder": value[len(prefix):], + } + }, + ) +} diff --git a/filters/util.go b/filters/util.go new file mode 100644 index 0000000..d2e2462 --- /dev/null +++ b/filters/util.go @@ -0,0 +1,10 @@ +package filters + +import "maps" + +func mergeResults(dst MatchResult, src MatchResult) { + if src == nil { + return + } + maps.Copy(dst, src) +} diff --git a/filters/vk_filters/message.go b/filters/vk_filters/message.go new file mode 100644 index 0000000..347bf5d --- /dev/null +++ b/filters/vk_filters/message.go @@ -0,0 +1,26 @@ +// Пакет vkfilters предоставляет фильтры, специфичные для работы с объектами VK API. +package vkfilters + +import ( + "github.com/SevereCloud/vksdk/v3/object" + "txts.su/vkc/filters" +) + +const ( + VkApiField filters.FieldDescriptor = "vkapi" + MessageField filters.FieldDescriptor = "message" +) + +// Создает фильтр, проверяющий текст сообщения на точное совпадение. +func MessageText(match string) filters.Filter { + return filters.NewFilter(MessageField, func(value object.MessagesMessage) bool { + return value.Text == match + }) +} + +// Создает фильтр, проверяющий ID беседы (peer ID) у сообщения. +func MessagePeerID(match int) filters.Filter { + return filters.NewFilter(MessageField, func(value object.MessagesMessage) bool { + return value.PeerID == match + }) +} diff --git a/go.mod b/go.mod index 9fd3de0..b8c16f9 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,10 @@ module txts.su/vkc go 1.26.0 -require github.com/SevereCloud/vksdk/v3 v3.2.2 +require ( + github.com/SevereCloud/vksdk/v3 v3.2.2 + txts.su/grfilt v1.0.0 +) require ( github.com/klauspost/compress v1.18.0 // indirect diff --git a/go.sum b/go.sum index c07a5ff..96c1357 100644 --- a/go.sum +++ b/go.sum @@ -18,3 +18,5 @@ golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +txts.su/grfilt v1.0.0 h1:fKdsCSBahqZBmLTbMv8h/vt2ArfQRgPay6c3jugfrlw= +txts.su/grfilt v1.0.0/go.mod h1:eiaLK1X7tmV4XCJxn8nk/B8XBIF45V3DDd7BvxGEfJo= diff --git a/split_args.go b/split_args.go index d622266..57fa943 100644 --- a/split_args.go +++ b/split_args.go @@ -2,6 +2,8 @@ package vkc import ( "strings" + + "txts.su/vkc/filters" ) // Разбиение строки на аргументы по пробелам. @@ -12,3 +14,17 @@ func SplitArgs(s string) []string { } return strings.Fields(s) } + +// Формирует аргументы команды из результатов извлечения фильтра. +func ArgumentsFromMatch(m filters.MatchResult) []string { + if m == nil { + return []string{} + } + if rem, ok := m["prefix_filter_remainder"].(string); ok { + return SplitArgs(rem) + } + if groups, ok := m["command_regex_filter_groups"].([]string); ok { + return groups + } + return []string{} +}