From 180bfc8820df8354826e0698b28992b9ad5d238c Mon Sep 17 00:00:00 2001 From: EgorBron Date: Sat, 13 Jun 2026 02:46:06 +0300 Subject: [PATCH 1/3] feat: add filters package --- command_handler.go | 28 +++++++++-- commands.go | 45 ++++++++++++++++-- filters/composite_filter.go | 39 +++++++++++++++ filters/filter_item.go | 42 ++++++++++++++++ filters/filters.go | 36 ++++++++++++++ filters/list.go | 17 +++++++ filters/text.go | 90 +++++++++++++++++++++++++++++++++++ filters/util.go | 8 ++++ filters/vk_filters/message.go | 26 ++++++++++ 9 files changed, 322 insertions(+), 9 deletions(-) create mode 100644 filters/composite_filter.go create mode 100644 filters/filter_item.go create mode 100644 filters/filters.go create mode 100644 filters/list.go create mode 100644 filters/text.go create mode 100644 filters/util.go create mode 100644 filters/vk_filters/message.go diff --git a/command_handler.go b/command_handler.go index 56a9ef6..429ecf9 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,31 @@ 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 map[string]any) { + return handler.Filter.Eval(map[filters.FieldDescriptor]any{ + vkfilters.MessageField: ctx.Message, + vkfilters.VkApiField: ctx.VK, + filters.TextField: prefixRemainder, + }) } // Метод для проверки доступности команды для пользователя. diff --git a/commands.go b/commands.go index 47b29ec..ea0371f 100644 --- a/commands.go +++ b/commands.go @@ -23,6 +23,12 @@ 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 +48,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 +72,7 @@ type Commands[DEPS any] struct { OnCommandError *func(ctx CommandContext[DEPS], err error) } -// Поиск команды по строке. Возвращает найденный обработчик и остаток строки (т.е. без названия команды). +// ППоиск команды по строке. Возвращает найденный обработчик и остаток строки (т.е. без названия команды). // // Поскольку срезы в Go являются упорядоченными, то при поиске приоритет будут иметь обработчики, расположенные ближе к началу среза. // @@ -75,6 +85,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 +98,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 +142,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 +169,27 @@ 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 remainder string + for _, h := range handlers { + if r, s := h.IsNotFiltered(cmdCtx, rawCmd); r { + handler = h + + if rs, ok := s["command_regex_filter_groups"].(string); ok { + remainder = rs + } + + if rs, ok := s["prefix_filter_remainder"].(string); ok { + remainder = rs + } + + break + } + } + if handler == nil { + handler, remainder = FindCommand(rawCmd, handlers) + } + if handler == nil { if commands.OnUnknownCommand != nil { logDeprecationWarning("OnUnknownCommand") @@ -163,7 +198,7 @@ func (commands *Commands[any]) ProcessCommands(ctx context.Context, vk *api.VK, return ErrCommandNotFound } - cmdCtx.Arguments = SplitArgs(remaining) + cmdCtx.Arguments = SplitArgs(remainder) if !handler.IsAccessAvailable(cmdCtx) { if commands.OnNoPermissions != nil { diff --git a/filters/composite_filter.go b/filters/composite_filter.go new file mode 100644 index 0000000..c5bb443 --- /dev/null +++ b/filters/composite_filter.go @@ -0,0 +1,39 @@ +package filters + +import "maps" + +// Представляет композицию нескольких фильтров. +// +// Фильтр срабатывает только если все входящие в него фильтры срабатывают успешно (логическое И). +type CompositeFilter struct { + filters []Filter +} + +// Создает новый композитный фильтр из переданных фильтров +func New(items ...Filter) *CompositeFilter { + return &CompositeFilter{filters: items} +} + +// Добавляет дополнительные фильтры к текущим и возвращает новый композитный фильтр +func (cf *CompositeFilter) And(otherFilters ...Filter) Filter { + return combineFilters(cf.filters, otherFilters) +} + +// Вычисляет результат композитного фильтра путем последовательной проверки всех входящих фильтров. +// +// Возвращает false если любой из фильтров не прошел проверку. +// +// Побочные значения от всех фильтров объединяются в итоговый словарь. +func (cf *CompositeFilter) Eval(input map[FieldDescriptor]any) (bool, map[string]any) { + sideValues := make(map[string]any) + + for _, f := range cf.filters { + ok, side := f.Eval(input) + if !ok { + return false, sideValues + } + + maps.Copy(sideValues, side) + } + return true, sideValues +} diff --git a/filters/filter_item.go b/filters/filter_item.go new file mode 100644 index 0000000..3f39489 --- /dev/null +++ b/filters/filter_item.go @@ -0,0 +1,42 @@ +package filters + +// Определяет поведение фильтра при его вычислении. +// +// - (по умолчанию) если установлено в `false`, фильтр считается успешно выполненным, только если запрашиваемое поле было во входных данных; +// - если установлено в `true`, фильтр считается успешно выполненным, даже если запрашиваемое поле отсутсвовало во входных данных. Такой фильтр стоит воспринимать как "необязательный". +var FiltersReturnTrueIfFieldNotFound = false + +// Представляет самую малую единицу фильтра - пару запрашиваемого поля и предиката +type FilterItem[T any] struct { + // Описание запрашиваемого поля + fieldName FieldDescriptor + // Предикат фильтра. Может вернуть побочные значения, которые могут использоваться для понимания результата работы предиката. + fn func(T) (bool, map[string]any) +} + +// Комбинирует текущий фильтр с переданными в композицию фильтров (см. [CompositeFilter]) +func (fi *FilterItem[T]) And(otherFilters ...Filter) Filter { + return combineFilters([]Filter{fi}, otherFilters) +} + +// Вычисляет значение фильтра. +// +// Если при вычислении запрашиваемое поле было найдено во входных данных, предикат выполняется. В противном случае возвращается значение переменной [FiltersReturnTrueIfFieldNotFound]. +// +// Предикат может вернуть побочные значения, которые могут использоваться для понимания результата работы фильтра. +func (fi *FilterItem[T]) Eval(input map[FieldDescriptor]any) (bool, map[string]any) { + val, ok := input[fi.fieldName].(T) + if !ok { + return FiltersReturnTrueIfFieldNotFound, nil + } + + return fi.fn(val) +} + +// Создает новый фильтр +func NewFilter[T any](fieldName FieldDescriptor, fn func(value T) (result bool, sideValues map[string]any)) Filter { + return &FilterItem[T]{ + fieldName: fieldName, + fn: fn, + } +} diff --git a/filters/filters.go b/filters/filters.go new file mode 100644 index 0000000..65f770c --- /dev/null +++ b/filters/filters.go @@ -0,0 +1,36 @@ +// Пакет filters предоставляет систему компонуемых фильтров для выбора объектов по условиям. +// +// Основные концепции: +// - Filter - интерфейс для любого фильтра с методами And() и Eval(). +// - FilterItem[T] - базовый фильтр, работающий с одним полем и предикатом. +// - CompositeFilter - композиция нескольких фильтров (логическое И). +// - FieldDescriptor - описание поля для фильтрации. +// +// Пакет не привязан к vkc. Фильтры можно использовать где и как угодно. +// +// Пример использования: +// +// filter := filters.New( +// filters.Text("hello"), +// filters.TextPrefix("hello") +// ) +// ok, sideValues := filter.Eval(map[filters.FieldDescriptor]any{ +// filters.TextField: "hello", +// }) +package filters + +// Описание поля входных данных фильтра +type FieldDescriptor string + +// Определяет интерфейс для фильтра +type Filter interface { + // Комбинирует текущий фильтр с другими фильтрами в новый композитный фильтр (см. [CompositeFilter]) + And(otherFilters ...Filter) Filter + + // Вычисляет результат фильтра по входным данным. + // + // Возвращает: + // - result: true если фильтр успешно применен, иначе false. + // - sideValues: побочные значения, которые могут быть использованы для анализа результата работы фильтра. + Eval(input map[FieldDescriptor]any) (result bool, sideValues map[string]any) +} diff --git a/filters/list.go b/filters/list.go new file mode 100644 index 0000000..6228dab --- /dev/null +++ b/filters/list.go @@ -0,0 +1,17 @@ +package filters + +import "slices" + +// Создает новый фильтр, срабатывающий только при совпадении с любым элементом из среза. +// Элементы, стоящие в срезе первее, имеют приоритет при поиске. +// +// Требует указания поля фильтра. +// +// Возвращаемые побочные значения: +// - `list_filter_match_index` - индекс совпавшего элемента или -1 при отсутствии совпадений. +func InList[T comparable](field FieldDescriptor, list []T) Filter { + return NewFilter(field, func(value T) (bool, map[string]any) { + idx := slices.Index(list, value) + return idx >= 0, map[string]any{"list_filter_match_index": idx} + }) +} diff --git a/filters/text.go b/filters/text.go new file mode 100644 index 0000000..da57060 --- /dev/null +++ b/filters/text.go @@ -0,0 +1,90 @@ +package filters + +import ( + "regexp" + "strings" +) + +const ( + TextField FieldDescriptor = "text" +) + +// Создает новый фильтр, срабатывающий только при полном совпадении строки. +// +// Требует поле фильтра: [TextField] +func Text(match string) Filter { + return NewFilter(TextField, func(value string) (bool, map[string]any) { + return value == match, nil + }) +} + +// Создает новый фильтр, срабатывающий только при совпадении с регулярным выражением. +// +// Требует поле фильтра: [TextField] +// +// Возвращаемые побочные значения: +// - `command_regex_filter_groups` - группы захвата в регулярном выражении. +func TextRegexCompiled(re *regexp.Regexp) Filter { + return NewFilter(TextField, func(value string) (bool, map[string]any) { + matches := re.FindStringSubmatch(value) + if matches == nil { + return false, nil + } + groups := []string{} + if len(matches) > 1 { + groups = append(groups, matches[1:]...) + } + return true, map[string]any{"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 NewFilter(TextField, func(value string) (bool, map[string]any) { + if !strings.HasPrefix(value, prefix) { + return false, nil + } + remainder := value[len(prefix):] + return true, map[string]any{"prefix_filter_remainder": remainder} + }) +} + +// Создает новый фильтр, срабатывающий только при совпадении с любым префиксом из среза. +// Префиксы, находящиеся в срезе первее, при поиске имеют приоритет над префиксами после них. +// +// Требует поле фильтра: [TextField] +// +// Возвращаемые побочные значения: +// - `prefix_filter_match` - совпавший префикс; +// - `prefix_filter_remainder` - остаток после исключения префикса. +func TextPrefixListOf(prefixList []string) Filter { + return NewFilter(TextField, func(value string) (bool, map[string]any) { + for _, p := range prefixList { + if strings.HasPrefix(value, p) { + remainder := value[len(p):] + return true, map[string]any{ + "prefix_filter_match": p, + "prefix_filter_remainder": remainder, + } + } + } + return false, nil + }) +} diff --git a/filters/util.go b/filters/util.go new file mode 100644 index 0000000..fd60b9a --- /dev/null +++ b/filters/util.go @@ -0,0 +1,8 @@ +package filters + +func combineFilters(existing []Filter, additional []Filter) *CompositeFilter { + allFilters := make([]Filter, 0, len(existing)+len(additional)) + allFilters = append(allFilters, existing...) + allFilters = append(allFilters, additional...) + return New(allFilters...) +} diff --git a/filters/vk_filters/message.go b/filters/vk_filters/message.go new file mode 100644 index 0000000..4fde82b --- /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, map[string]any) { + return value.Text == match, nil + }) +} + +// Создает фильтр, проверяющий ID беседы (peer ID) у сообщения. +func MessagePeerID(match int) filters.Filter { + return filters.NewFilter(MessageField, func(value object.MessagesMessage) (bool, map[string]any) { + return value.PeerID == match, nil + }) +} From 40322e9a46ca6b0ee5a5436e7e1958d342ac9b65 Mon Sep 17 00:00:00 2001 From: EgorBron Date: Sat, 13 Jun 2026 03:05:23 +0300 Subject: [PATCH 2/3] fix: add nil check for side values --- commands.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/commands.go b/commands.go index ea0371f..1fb1074 100644 --- a/commands.go +++ b/commands.go @@ -175,6 +175,10 @@ func (commands *Commands[any]) ProcessCommands(ctx context.Context, vk *api.VK, if r, s := h.IsNotFiltered(cmdCtx, rawCmd); r { handler = h + if s == nil { + break + } + if rs, ok := s["command_regex_filter_groups"].(string); ok { remainder = rs } From 65983a7ff5bd1eff0a58a81fc6f3fbae2ae234cd Mon Sep 17 00:00:00 2001 From: EgorBron Date: Thu, 18 Jun 2026 21:18:52 +0300 Subject: [PATCH 3/3] feat: changed filter api to use grfilt and extractors --- command_context.go | 16 ++++++-- command_handler.go | 16 ++++++-- commands.go | 29 ++++++------- filters/composite.go | 53 ++++++++++++++++++++++++ filters/composite_filter.go | 39 ------------------ filters/either.go | 56 ++++++++++++++++++++++++++ filters/extractable.go | 45 +++++++++++++++++++++ filters/filter_item.go | 42 ------------------- filters/filters.go | 48 ++++++---------------- filters/leaf.go | 28 +++++++++++++ filters/list.go | 12 ++++-- filters/text.go | 76 +++++++++++++++++++++-------------- filters/util.go | 18 +++++---- filters/vk_filters/message.go | 8 ++-- go.mod | 5 ++- go.sum | 2 + split_args.go | 16 ++++++++ 17 files changed, 321 insertions(+), 188 deletions(-) create mode 100644 filters/composite.go delete mode 100644 filters/composite_filter.go create mode 100644 filters/either.go create mode 100644 filters/extractable.go delete mode 100644 filters/filter_item.go create mode 100644 filters/leaf.go 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 429ecf9..56c155c 100644 --- a/command_handler.go +++ b/command_handler.go @@ -57,12 +57,22 @@ type CommandHandler[DEPS any] struct { } // Метод для проверки совпадения контекста с фильтром команды. -func (handler *CommandHandler[T]) IsNotFiltered(ctx CommandContext[T], prefixRemainder string) (result bool, sideValues map[string]any) { - return handler.Filter.Eval(map[filters.FieldDescriptor]any{ +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 1fb1074..5904517 100644 --- a/commands.go +++ b/commands.go @@ -17,6 +17,7 @@ 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) { @@ -170,28 +171,17 @@ func (commands *Commands[any]) ProcessCommands(ctx context.Context, vk *api.VK, commands.cmdMutex.RUnlock() var handler *CommandHandler[any] - var remainder string + var matchResult filters.MatchResult + var legacyRemainder string for _, h := range handlers { - if r, s := h.IsNotFiltered(cmdCtx, rawCmd); r { + if matched, match := h.IsNotFiltered(cmdCtx, rawCmd); matched { handler = h - - if s == nil { - break - } - - if rs, ok := s["command_regex_filter_groups"].(string); ok { - remainder = rs - } - - if rs, ok := s["prefix_filter_remainder"].(string); ok { - remainder = rs - } - + matchResult = match break } } if handler == nil { - handler, remainder = FindCommand(rawCmd, handlers) + handler, legacyRemainder = FindCommand(rawCmd, handlers) } if handler == nil { @@ -202,7 +192,12 @@ func (commands *Commands[any]) ProcessCommands(ctx context.Context, vk *api.VK, return ErrCommandNotFound } - cmdCtx.Arguments = SplitArgs(remainder) + 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/composite_filter.go b/filters/composite_filter.go deleted file mode 100644 index c5bb443..0000000 --- a/filters/composite_filter.go +++ /dev/null @@ -1,39 +0,0 @@ -package filters - -import "maps" - -// Представляет композицию нескольких фильтров. -// -// Фильтр срабатывает только если все входящие в него фильтры срабатывают успешно (логическое И). -type CompositeFilter struct { - filters []Filter -} - -// Создает новый композитный фильтр из переданных фильтров -func New(items ...Filter) *CompositeFilter { - return &CompositeFilter{filters: items} -} - -// Добавляет дополнительные фильтры к текущим и возвращает новый композитный фильтр -func (cf *CompositeFilter) And(otherFilters ...Filter) Filter { - return combineFilters(cf.filters, otherFilters) -} - -// Вычисляет результат композитного фильтра путем последовательной проверки всех входящих фильтров. -// -// Возвращает false если любой из фильтров не прошел проверку. -// -// Побочные значения от всех фильтров объединяются в итоговый словарь. -func (cf *CompositeFilter) Eval(input map[FieldDescriptor]any) (bool, map[string]any) { - sideValues := make(map[string]any) - - for _, f := range cf.filters { - ok, side := f.Eval(input) - if !ok { - return false, sideValues - } - - maps.Copy(sideValues, side) - } - return true, sideValues -} 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/filter_item.go b/filters/filter_item.go deleted file mode 100644 index 3f39489..0000000 --- a/filters/filter_item.go +++ /dev/null @@ -1,42 +0,0 @@ -package filters - -// Определяет поведение фильтра при его вычислении. -// -// - (по умолчанию) если установлено в `false`, фильтр считается успешно выполненным, только если запрашиваемое поле было во входных данных; -// - если установлено в `true`, фильтр считается успешно выполненным, даже если запрашиваемое поле отсутсвовало во входных данных. Такой фильтр стоит воспринимать как "необязательный". -var FiltersReturnTrueIfFieldNotFound = false - -// Представляет самую малую единицу фильтра - пару запрашиваемого поля и предиката -type FilterItem[T any] struct { - // Описание запрашиваемого поля - fieldName FieldDescriptor - // Предикат фильтра. Может вернуть побочные значения, которые могут использоваться для понимания результата работы предиката. - fn func(T) (bool, map[string]any) -} - -// Комбинирует текущий фильтр с переданными в композицию фильтров (см. [CompositeFilter]) -func (fi *FilterItem[T]) And(otherFilters ...Filter) Filter { - return combineFilters([]Filter{fi}, otherFilters) -} - -// Вычисляет значение фильтра. -// -// Если при вычислении запрашиваемое поле было найдено во входных данных, предикат выполняется. В противном случае возвращается значение переменной [FiltersReturnTrueIfFieldNotFound]. -// -// Предикат может вернуть побочные значения, которые могут использоваться для понимания результата работы фильтра. -func (fi *FilterItem[T]) Eval(input map[FieldDescriptor]any) (bool, map[string]any) { - val, ok := input[fi.fieldName].(T) - if !ok { - return FiltersReturnTrueIfFieldNotFound, nil - } - - return fi.fn(val) -} - -// Создает новый фильтр -func NewFilter[T any](fieldName FieldDescriptor, fn func(value T) (result bool, sideValues map[string]any)) Filter { - return &FilterItem[T]{ - fieldName: fieldName, - fn: fn, - } -} diff --git a/filters/filters.go b/filters/filters.go index 65f770c..9c4357b 100644 --- a/filters/filters.go +++ b/filters/filters.go @@ -1,36 +1,12 @@ -// Пакет filters предоставляет систему компонуемых фильтров для выбора объектов по условиям. -// -// Основные концепции: -// - Filter - интерфейс для любого фильтра с методами And() и Eval(). -// - FilterItem[T] - базовый фильтр, работающий с одним полем и предикатом. -// - CompositeFilter - композиция нескольких фильтров (логическое И). -// - FieldDescriptor - описание поля для фильтрации. -// -// Пакет не привязан к vkc. Фильтры можно использовать где и как угодно. -// -// Пример использования: -// -// filter := filters.New( -// filters.Text("hello"), -// filters.TextPrefix("hello") -// ) -// ok, sideValues := filter.Eval(map[filters.FieldDescriptor]any{ -// filters.TextField: "hello", -// }) -package filters - -// Описание поля входных данных фильтра -type FieldDescriptor string - -// Определяет интерфейс для фильтра -type Filter interface { - // Комбинирует текущий фильтр с другими фильтрами в новый композитный фильтр (см. [CompositeFilter]) - And(otherFilters ...Filter) Filter - - // Вычисляет результат фильтра по входным данным. - // - // Возвращает: - // - result: true если фильтр успешно применен, иначе false. - // - sideValues: побочные значения, которые могут быть использованы для анализа результата работы фильтра. - Eval(input map[FieldDescriptor]any) (result bool, sideValues map[string]any) -} +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 index 6228dab..9bb64c9 100644 --- a/filters/list.go +++ b/filters/list.go @@ -10,8 +10,12 @@ import "slices" // Возвращаемые побочные значения: // - `list_filter_match_index` - индекс совпавшего элемента или -1 при отсутствии совпадений. func InList[T comparable](field FieldDescriptor, list []T) Filter { - return NewFilter(field, func(value T) (bool, map[string]any) { - idx := slices.Index(list, value) - return idx >= 0, map[string]any{"list_filter_match_index": idx} - }) + 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 index da57060..6652e77 100644 --- a/filters/text.go +++ b/filters/text.go @@ -13,8 +13,8 @@ const ( // // Требует поле фильтра: [TextField] func Text(match string) Filter { - return NewFilter(TextField, func(value string) (bool, map[string]any) { - return value == match, nil + return NewFilter(TextField, func(value string) bool { + return value == match }) } @@ -25,17 +25,19 @@ func Text(match string) Filter { // Возвращаемые побочные значения: // - `command_regex_filter_groups` - группы захвата в регулярном выражении. func TextRegexCompiled(re *regexp.Regexp) Filter { - return NewFilter(TextField, func(value string) (bool, map[string]any) { - matches := re.FindStringSubmatch(value) - if matches == nil { - return false, nil - } - groups := []string{} - if len(matches) > 1 { - groups = append(groups, matches[1:]...) - } - return true, map[string]any{"command_regex_filter_groups": groups} - }) + 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} + }, + ) } // Создает новый фильтр, срабатывающий только при совпадении с регулярным выражением в виде строки. @@ -57,13 +59,23 @@ func TextRegex(pattern string) Filter { // Возвращаемые побочные значения: // - `prefix_filter_remainder` - остаток после исключения префикса. func TextPrefix(prefix string) Filter { - return NewFilter(TextField, func(value string) (bool, map[string]any) { - if !strings.HasPrefix(value, prefix) { - return false, nil + 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 } - remainder := value[len(prefix):] - return true, map[string]any{"prefix_filter_remainder": remainder} - }) + } + return "", false } // Создает новый фильтр, срабатывающий только при совпадении с любым префиксом из среза. @@ -75,16 +87,20 @@ func TextPrefix(prefix string) Filter { // - `prefix_filter_match` - совпавший префикс; // - `prefix_filter_remainder` - остаток после исключения префикса. func TextPrefixListOf(prefixList []string) Filter { - return NewFilter(TextField, func(value string) (bool, map[string]any) { - for _, p := range prefixList { - if strings.HasPrefix(value, p) { - remainder := value[len(p):] - return true, map[string]any{ - "prefix_filter_match": p, - "prefix_filter_remainder": remainder, - } + 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 false, nil - }) + return MatchResult{ + "prefix_filter_match": prefix, + "prefix_filter_remainder": value[len(prefix):], + } + }, + ) } diff --git a/filters/util.go b/filters/util.go index fd60b9a..d2e2462 100644 --- a/filters/util.go +++ b/filters/util.go @@ -1,8 +1,10 @@ -package filters - -func combineFilters(existing []Filter, additional []Filter) *CompositeFilter { - allFilters := make([]Filter, 0, len(existing)+len(additional)) - allFilters = append(allFilters, existing...) - allFilters = append(allFilters, additional...) - return New(allFilters...) -} +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 index 4fde82b..347bf5d 100644 --- a/filters/vk_filters/message.go +++ b/filters/vk_filters/message.go @@ -13,14 +13,14 @@ const ( // Создает фильтр, проверяющий текст сообщения на точное совпадение. func MessageText(match string) filters.Filter { - return filters.NewFilter(MessageField, func(value object.MessagesMessage) (bool, map[string]any) { - return value.Text == match, nil + 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, map[string]any) { - return value.PeerID == match, nil + 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{} +}