From 5efe61f801136c6557bbeadc2fdc0c41da983259 Mon Sep 17 00:00:00 2001 From: Rogee Date: Sun, 7 Jun 2026 06:45:22 +0800 Subject: [PATCH] feat(automation): align message filters --- docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md | 31 +-- internal/automation/condition_filter.go | 262 ++++++++++++++++++ internal/automation/condition_filter_test.go | 101 ++++++- internal/automation/condition_validator.go | 18 ++ .../automation/condition_validator_test.go | 26 +- internal/automation/listener_test.go | 49 ++++ 6 files changed, 469 insertions(+), 18 deletions(-) diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index 21e31803..3053ed9b 100644 --- a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md +++ b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md @@ -49,12 +49,12 @@ Hermes task landing checklist: ## Current Baseline -- Current tracking checkpoint: 2026-06-07 plan landing checkpoint, prepared as `docs: finalize parity tracking plan`. -- Latest implementation checkpoint: 2026-06-07 P3.74 contact type default parity, committed as `792786d feat(contacts): default contact type`. -- Latest documentation/tooling checkpoint: this tracker update makes the next executable slice explicit: P3.75 automation message filter parity from the inspected Chatwoot automation message filter contract. No route artifacts change. +- Current tracking checkpoint: 2026-06-07 P3.75 automation message filter parity, prepared as `feat(automation): align message filters`. +- Latest implementation checkpoint: this checkpoint, prepared as `feat(automation): align message filters`. +- Latest documentation/tooling checkpoint: this tracker update records Chatwoot automation message filter parity for `message_created` rules. No route artifacts change. - Plan landing status: complete for the current known Hermes plans and user-confirmed scope. Future work should update this file directly instead of opening a parallel tracker. -- Worktree status at this implementation checkpoint: contacts now follow Chatwoot default contact type semantics on all create/import paths: blank `contact_type` becomes `visitor`, and visitor contacts with email, phone, or social additional attributes upgrade to `lead`. This closes the CRM v2/import drift where CSV-created contacts without an explicit `contact_type` could remain empty and disappear from lead-only CRM surfaces. Existing P3.73 conversation filter permission parity, P3.72 account enabled-feature serialization parity, P3.71 contact referer filter parity, P5.8c contact import search indexing, P3.70 global contact search resolved-scope parity, P3.69 contact index/filter/export resolved-scope parity, P3.68 contact filter payload parity, P3.64-P3.67 conversation filter payload parity, P3.63 search message-order parity, P3.62 trimmed search payload fields, P3.61 sender-name, P3.60 numeric message-type parity, and prior report/profile/channel/contact checkpoints remain implemented. Live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack. -- Next executable implementation checkpoint: P3.75 automation message filter parity, unless the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack is available for B12 live smoke first. Re-run Phase 6 placeholder audit after future route/smoke changes. +- Worktree status at this implementation checkpoint: automation rule matching now evaluates Chatwoot `message_created` message filters from the triggering message payload: `content` uses processed message content when available and falls back to persisted content, `private_note` maps to message private state, and `message_type` accepts Chatwoot numeric enum values plus local string values. Existing P3.74 contact type default parity, P3.73 conversation filter permission parity, P3.72 account enabled-feature serialization parity, P3.71 contact referer filter parity, P5.8c contact import search indexing, P3.70 global contact search resolved-scope parity, and prior checkpoints remain implemented. Live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack. +- Next executable implementation checkpoint: continue Phase 2/3 drift audit for the next reused-frontend mismatch, or run B12 live smoke when the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack is available. Re-run Phase 6 placeholder audit after future route/smoke changes. - `go test ./...` passes when run outside the restricted socket sandbox for the latest implementation baseline; the latest docs/tooling checkpoint verified `scripts/parity_frontend_smoke.sh --check` with workspace-local temp/cache dirs after `/tmp` was full. - Route dump succeeds with `967` registered routes after profile MFA route tracking. - Route parity artifacts now exist under `docs/parity/` and are generated by `cmd/route_parity`. @@ -69,7 +69,7 @@ This section is the quick entry point for the next implementation turn. Use the | Track | Current state | Next owner action | Stop condition | | --- | --- | --- | --- | -| Backend 1:1 reused-frontend parity | Active. Tracked frontend-critical route parity is `0 missing` for the current audited set, but full parity is not proven until live reused-frontend smoke is clean. | Execute P3.75 automation message filter parity from the inspected current `reference/chatwoot` automation filters, then continue Phase 2/3 drift audit. | Every smoke/reference mismatch is converted into a named slice with owner files and tests. | +| Backend 1:1 reused-frontend parity | Active. Tracked frontend-critical route parity is `0 missing` for the current audited set, but full parity is not proven until live reused-frontend smoke is clean. | Continue Phase 2/3 drift audit from current `reference/chatwoot` controllers, serializers, and frontend API clients, or run B12 live smoke when the full stack is available. | Every smoke/reference mismatch is converted into a named slice with owner files and tests. | | Tests and route artifacts | Required for every code checkpoint. Full `go test ./...` passes outside the restricted socket sandbox for the latest implementation baseline. | Run focused tests, full suite, `git diff --check`, and regenerate `docs/parity/*` only when route/tracked-set changes. | No checkpoint closes without verification or a documented environment blocker. | | Meilisearch search | In scope and mandatory. The Hermes search plan is landed into Phase 1/B6/P5.3; production DB search is not an acceptable final path. | Reopen only from live Meilisearch gate failure, stale index mutation path, or frontend search payload drift. | Meilisearch-first behavior is verified for global/entity search and indexing hooks. | | Enterprise paid features | In scope except SSO/SAML/LDAP/OIDC. SLA, Audit, CustomRole, AgentCapacity, Captain/Copilot, CSAT, InboxLimit, automation, macros, assignment policies, and limits remain tracked. | Use B7-B12 and Phase 5 rows as the source of truth; split new work only from fresh reference or smoke evidence. | Reused Chatwoot enterprise screens/API smoke without adapters. | @@ -77,7 +77,7 @@ This section is the quick entry point for the next implementation turn. Use the | Placeholder burn-down | Phase 6 is in Review; current audit shows only webhook nil-handler fallbacks still call `chatwootParityStub`. | Re-run `rg` placeholder audit after route/smoke changes and burn down any frontend-reachable stub. | No account/contact/conversation/message/inbox/widget/public critical stub is ownerless. | | Live B12 smoke | Harness exists; live run is environment-dependent. | Run when PostgreSQL, Redis, Meilisearch, GoChat, Vite, and Chrome are available. | `docs/parity/frontend_smoke_report.md` maps every failure to an owner slice or records clean pass. | -Execution rule for the next turn: start with P3.75 automation message filter parity unless the full B12 live stack is available. Keep implementation slices small, update this document in the same commit, and do not create parallel trackers. Treat Phase 6 as a repeatable guard after route/smoke changes, not as a blocking implementation slice while the audit remains clean. +Execution rule for the next turn: start with Phase 2/3 drift audit unless the full B12 live stack is available. Keep implementation slices small, update this document in the same commit, and do not create parallel trackers. Treat Phase 6 as a repeatable guard after route/smoke changes, not as a blocking implementation slice while the audit remains clean. ## Planning Control Board @@ -96,18 +96,16 @@ Next ordered checkpoints: | Order | Slice | Required outcome | Primary verification | | --- | --- | --- | --- | -| 1 | P3.75 automation message filter parity | Automation rule conditions for `message_created` match Chatwoot message filters: `message_type`, `private_note`, and `content`, including operator validation and evaluation against message event data. | Focused automation condition/listener tests, full `go test ./...`, and `git diff --check`. | -| 2 | Phase 2/3 audit pass | Route/controller/serializer drift found by B12 or new reference inspection is captured as named slices, not free-form TODOs. | Regenerated route parity artifacts and fixture-backed serializer tests. | -| 3 | Phase 6 placeholder burn-down | Remaining account/contact/conversation/message/inbox placeholder handlers are either real Chatwoot-compatible flows or explicitly tracked as unsupported reference gaps. | `rg` placeholder audit, route smoke, and endpoint-family tests. | -| 4 | B12 optional live smoke | Run the checked smoke harness in a full PostgreSQL/Redis/Meilisearch/Vite/Chrome environment and turn failures into named slices. | `docs/parity/frontend_smoke_report.md` pass/fail entries linked to owners. | +| 1 | Phase 2/3 audit pass | Route/controller/serializer drift found by B12 or new reference inspection is captured as named slices, not free-form TODOs. | Regenerated route parity artifacts and fixture-backed serializer tests. | +| 2 | Phase 6 placeholder burn-down | Remaining account/contact/conversation/message/inbox placeholder handlers are either real Chatwoot-compatible flows or explicitly tracked as unsupported reference gaps. | `rg` placeholder audit, route smoke, and endpoint-family tests. | +| 3 | B12 optional live smoke | Run the checked smoke harness in a full PostgreSQL/Redis/Meilisearch/Vite/Chrome environment and turn failures into named slices. | `docs/parity/frontend_smoke_report.md` pass/fail entries linked to owners. | Execution queue for the next agent turn: | Order | Slice ID | Why now | Required commit contents | | --- | --- | --- | --- | -| 1 | P3.75 automation message filter parity | Fresh reference inspection found Chatwoot `message_created` automation filters that are only partially represented in GoChat's condition matcher. | Code/tests for message event data matching, validator/operator behavior, and this tracker row moved to Review. | -| 2 | Phase 2/3 drift audit | Route parity is currently green for the tracked set, but only fresh reference/frontend inspection proves the next missing reused-frontend path. | Named drift rows with inspected reference files, fixture tests where behavior is known, regenerated route artifacts if the route set changes. | -| 3 | B12 live smoke | The harness is checked in; live pass/fail still needs the full PostgreSQL/Redis/Meilisearch/Vite/Chrome stack. | Updated smoke report with command, environment, failures, and linked owner rows. | +| 1 | Phase 2/3 drift audit | Route parity is currently green for the tracked set, but only fresh reference/frontend inspection proves the next missing reused-frontend path. | Named drift rows with inspected reference files, fixture tests where behavior is known, regenerated route artifacts if the route set changes. | +| 2 | B12 live smoke | The harness is checked in; live pass/fail still needs the full PostgreSQL/Redis/Meilisearch/Vite/Chrome stack. | Updated smoke report with command, environment, failures, and linked owner rows. | | Guard | Phase 6 placeholder audit | Current audit is clean for reused-frontend critical handlers; re-run after future route/smoke changes so no frontend-critical stub becomes ownerless. | `rg` audit result, either a burn-down implementation or explicit non-frontend fallback classification, focused tests if code changes. | Slice lifecycle: @@ -158,7 +156,7 @@ This table is the shortest authoritative handoff view. If an older lower section | Priority | Workstream | Current state | Next checkpoint | Commit close rule | | --- | --- | --- | --- | --- | -| 0 | P3.75 automation message filter parity | Todo from fresh reference inspection: Chatwoot exposes `message_created` automation filters for `message_type`, `private_note`, and `content`; GoChat validation whitelists these attributes but the matcher still skips message-join/message-event attributes. | Implement message-event condition matching for Chatwoot numeric/string message types, private-note booleans, and content equality/contains operators; keep `AND`/`OR` query operator behavior covered. | Focused automation condition validator/filter/listener tests, full `go test ./...`, and `git diff --check` must pass. No route artifacts change. | +| 0 | P3.75 automation message filter parity | Implemented for reused automation `message_created` rules: GoChat now evaluates `message_type`, `private_note`, and `content` against the triggering message payload, including processed-content fallback and Chatwoot numeric message-type values. | Keep in Review; reopen from B12 automation smoke or fresh reference evidence for SQL-relation based message filtering beyond the triggering message, additional processed-content sources, or exact cross-group query precedence. | Focused automation condition validator/filter/listener tests, full `go test ./...`, and `git diff --check` must pass. No route artifacts change. | | 0 | P3.74 contact type default parity | Implemented for direct create and CSV import paths: blank GoChat contacts now default to Chatwoot `visitor`, and visitor contacts with email, phone, or social details upgrade to `lead`. | Keep in Review; reopen from B12 CRM/import smoke or fresh reference evidence for channel-specific social detail keys or bulk-import merge edge cases. | Focused contact model/import tests, full `go test ./...`, and `git diff --check` must pass. No route artifacts change. | | 0 | P3.73 conversation filter permission parity | Implemented for reused dashboard advanced filters: `POST /conversations/filter` now scopes results and meta counts through Chatwoot-style inbox visibility before applying payload/status/label filters. Administrators see all account conversations; agents see only assigned inbox conversations. | Keep in Review; reopen from B12 conversation filter smoke or fresh reference evidence for enterprise custom-role overrides beyond the current non-SSO scope. | Focused conversation service filter permission tests, full `go test ./...`, and `git diff --check` must pass. No route artifacts change. | | 0 | P3.72 account enabled-features serialization parity | Implemented for reused frontend account feature gates: GoChat account payloads now serialize `features` like Chatwoot `Account#enabled_features`, seeding default enabled flags from `config/features.yml`, including explicit true flags such as `crm_v2`, and omitting false flags. | Keep in Review; reopen from B12 account/CRM smoke or fresh reference evidence for installation-config default feature drift, cloud-only feature filtering, or bitfield migration needs. | Focused account handler tests, full `go test ./...`, and `git diff --check` must pass. No route artifacts change. | @@ -241,7 +239,7 @@ These rows are the executable development plan from this point forward. A checkp | ID | Owner files | Reference files | Work to land | Exit gate | | --- | --- | --- | --- | --- | -| P3.75 automation message filter parity | `internal/automation/condition_filter.go`, `internal/automation/condition_validator.go`, `internal/automation/listener.go`, `internal/automation/model.go`, `internal/automation/condition_filter_test.go`, `internal/automation/condition_validator_test.go`, `internal/automation/listener_test.go` | `reference/chatwoot/app/services/automation_rules/conditions_filter_service.rb`, `reference/chatwoot/app/services/automation_rules/condition_validation_service.rb`, `reference/chatwoot/lib/filters/filter_keys.yml`, `reference/chatwoot/app/javascript/dashboard/routes/dashboard/settings/automation/constants.js`, `reference/chatwoot/app/javascript/dashboard/helper/automationHelper.js` | Todo. Implement Chatwoot `message_created` message filter behavior for automation rules: `content` maps to message processed content where available, otherwise persisted message content; `private_note` maps to message private-note state; `message_type` accepts Chatwoot numeric enum values and local string values at the Go boundary. Supported operators are `equal_to`/`not_equal_to` for `message_type` and `private_note`, plus `equal_to`/`not_equal_to`/`contains`/`does_not_contain` for `content`. | Move to Review only after focused automation matcher/validator/listener tests cover all three message attributes and mixed query operators; full `go test ./...` and `git diff --check` must pass. No route artifacts change. | +| P3.75 automation message filter parity | `internal/automation/condition_filter.go`, `internal/automation/condition_validator.go`, `internal/automation/listener.go`, `internal/automation/model.go`, `internal/automation/condition_filter_test.go`, `internal/automation/condition_validator_test.go`, `internal/automation/listener_test.go` | `reference/chatwoot/app/services/automation_rules/conditions_filter_service.rb`, `reference/chatwoot/app/services/automation_rules/condition_validation_service.rb`, `reference/chatwoot/lib/filters/filter_keys.yml`, `reference/chatwoot/app/javascript/dashboard/routes/dashboard/settings/automation/constants.js`, `reference/chatwoot/app/javascript/dashboard/helper/automationHelper.js` | Automation `message_created` conditions now evaluate the triggering message payload like the inspected reference message filters: `content` uses `processed_message_content` from content attributes when available and falls back to `messages.content`; `private_note` maps to `messages.private`; `message_type` accepts Chatwoot enum integers (`incoming=0`, `outgoing=1`, `activity=2`, `template=3`) and local strings. Validation now restricts message filter operators to the Chatwoot filter key contract. | Review by `feat(automation): align message filters`; focused matcher tests cover content/private/message-type matches, non-matches, missing message data, and OR message groups; validator tests cover supported/unsupported message operators; listener test proves `message_created` rules execute or skip from message filters. Full `go test ./...` and `git diff --check` must pass. No route artifacts change. | | P3.74 contact type default parity | `internal/model/contact.go`, `internal/model/model_test.go`, `internal/service/contact_service_g3_test.go` | `reference/chatwoot/app/models/contact.rb`, `reference/chatwoot/app/services/contacts/sync_attributes.rb`, `reference/chatwoot/spec/models/contact_spec.rb`, `reference/chatwoot/spec/services/contacts/sync_attributes_spec.rb`, `reference/chatwoot/app/services/data_import/contact_manager.rb` | Contact persistence now mirrors Chatwoot enum/default behavior before CRM scopes see imported records: blank `contact_type` is normalized to `visitor`, and `visitor` contacts with email, phone number, or social additional attributes become `lead`. CSV imports without a `contact_type` column therefore create CRM-v2-visible leads when identity details are present and true visitors when they are absent. | Review by `feat(contacts): default contact type`; focused model/import tests assert default visitor, detail-based lead upgrade, and CSV import defaults; full `go test ./...` and `git diff --check`. No route artifacts change. | | P3.73 conversation filter permission parity | `internal/service/conversation_service.go`, `internal/service/conversation_service_test.go` | `reference/chatwoot/app/services/conversations/filter_service.rb`, `reference/chatwoot/app/services/conversations/permission_filter_service.rb`, `reference/chatwoot/app/controllers/api/v1/accounts/conversations_controller.rb#filter` | Advanced conversation filters now apply the Chatwoot permission base relation before payload/status/label filters. Non-admin agents are restricted to conversations whose inbox is in their account-scoped assigned inboxes; administrators keep full account visibility; missing account memberships return an empty result set. | Review by `feat(conversations): scope filter permissions`; focused service tests assert agent filtering hides unassigned-inbox matches while administrators still see all matching inboxes; full `go test ./...` and `git diff --check`. No route artifacts change. | | P3.72 account enabled-features serialization parity | `internal/handler/api/v1/account_handler.go`, `internal/handler/api/v1/account_handler_test.go` | `reference/chatwoot/app/models/concerns/featurable.rb`, `reference/chatwoot/app/views/api/v1/models/_account.json.jbuilder`, `reference/chatwoot/config/features.yml`, `reference/chatwoot/app/javascript/dashboard/store/modules/accounts.js` | Account show/list payloads now serialize `features` as Chatwoot `enabled_features` instead of raw GoChat `feature_flags`: default enabled flags are present for empty accounts, explicit true flags like `crm_v2` are included, and false flags are omitted so reused frontend feature gates see the same truthy-key contract. | Review by `feat(accounts): serialize enabled features`; focused handler tests assert default enabled flags, explicit `crm_v2`, and false-flag omission; full `go test ./...` and `git diff --check`. No route artifacts change. | @@ -2686,3 +2684,4 @@ Verification milestone gates: - 2026-06-07: P3.73 conversation filter permission checkpoint prepared as `feat(conversations): scope filter permissions`; audited Chatwoot `Conversations::FilterService`, `Conversations::PermissionFilterService`, and the reused `POST /conversations/filter` path. GoChat advanced conversation filters now apply assigned-inbox visibility for non-admin agents before payload/status/label filters and meta counts, while administrators keep full account visibility. Focused conversation service permission tests passed; full `go test ./...` and `git diff --check` passed. No route artifacts change. - 2026-06-07: P3.74 contact type default checkpoint prepared as `feat(contacts): default contact type`; audited Chatwoot contact enum defaults, `Contacts::SyncAttributes`, model specs, sync specs, and CSV import behavior. GoChat contacts now default blank `contact_type` to `visitor` and upgrade visitor contacts with email, phone, or social details to `lead`, closing the import/CRM-v2 drift where CSV-created contacts without an explicit type could stay empty. Focused contact model/import tests passed; full `go test ./...` and `git diff --check` passed. No route artifacts change. - 2026-06-07: Documentation checkpoint prepared as `docs: finalize parity tracking plan`; confirms `792786d feat(contacts): default contact type` is already committed, keeps `docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md` as the single active tracker, and lands P3.75 automation message filter parity as the next executable implementation slice. The row records inspected Chatwoot automation filter/validation/frontend files, Go owner files, message attribute contracts, and close gates. Verification for this docs-only checkpoint: `git diff --check`. +- 2026-06-07: P3.75 automation message filter checkpoint prepared as `feat(automation): align message filters`; audited Chatwoot automation `ConditionsFilterService`, `ConditionValidationService`, `filter_keys.yml`, and reused automation builder constants/helper. GoChat `message_created` automation rules now evaluate `message_type`, `private_note`, and `content` from the triggering message payload, including Chatwoot numeric message type values, processed-content fallback, boolean private-note matching, content equality/containment operators, and validator rejection for unsupported message operators. Focused automation matcher/validator/listener tests passed; full `go test ./...` and `git diff --check` passed. No route artifacts change. diff --git a/internal/automation/condition_filter.go b/internal/automation/condition_filter.go index 816035c4..3f2f19e8 100644 --- a/internal/automation/condition_filter.go +++ b/internal/automation/condition_filter.go @@ -3,8 +3,10 @@ package automation import ( "context" "fmt" + "strconv" "strings" + "github.com/gochat/gochat/internal/model" "gorm.io/gorm" ) @@ -29,10 +31,14 @@ func MatchConditions(ctx context.Context, db *gorm.DB, conditions Conditions, co // Separate conditions by type: direct attribute checks vs attribute_changed checks directConditions := Conditions{} changedConditions := Conditions{} + messageConditions := Conditions{} for _, c := range conditions { + attr := conditionAttribute(c) if c.FilterOperator == "attribute_changed" { changedConditions = append(changedConditions, c) + } else if isMessageConditionAttribute(attr) { + messageConditions = append(messageConditions, c) } else { directConditions = append(directConditions, c) } @@ -49,6 +55,17 @@ func MatchConditions(ctx context.Context, db *gorm.DB, conditions Conditions, co } } + // Evaluate message conditions from the triggering message payload. + if len(messageConditions) > 0 { + matched, err := matchMessageConditions(ctx, db, messageConditions, conv, eventData) + if err != nil { + return false, err + } + if !matched { + return false, nil + } + } + // Evaluate attribute_changed conditions if len(changedConditions) > 0 { matched, err := matchChangedConditions(changedConditions, eventData) @@ -63,6 +80,251 @@ func MatchConditions(ctx context.Context, db *gorm.DB, conditions Conditions, co return true, nil } +func conditionAttribute(c Condition) string { + if c.Attribute != "" { + return c.Attribute + } + return c.AttributeKey +} + +func isMessageConditionAttribute(attr string) bool { + switch attr { + case "content", "message_type", "private_note": + return true + default: + return false + } +} + +type messageConditionData struct { + Content string + MessageType string + Private bool +} + +func matchMessageConditions(ctx context.Context, db *gorm.DB, conditions Conditions, conv *ConversationForFilter, eventData map[string]interface{}) (bool, error) { + message, ok := messageDataForConditions(ctx, db, conv, eventData) + if !ok { + return false, nil + } + + andGroup := Conditions{} + orGroup := Conditions{} + for _, c := range conditions { + if strings.EqualFold(c.QueryOperator, "or") { + orGroup = append(orGroup, c) + } else { + andGroup = append(andGroup, c) + } + } + + for _, c := range andGroup { + matched, err := matchSingleMessageCondition(message, c) + if err != nil || !matched { + return matched, err + } + } + + if len(orGroup) == 0 { + return true, nil + } + for _, c := range orGroup { + matched, err := matchSingleMessageCondition(message, c) + if err != nil { + return false, err + } + if matched { + return true, nil + } + } + return false, nil +} + +func matchSingleMessageCondition(message messageConditionData, c Condition) (bool, error) { + attr := conditionAttribute(c) + op := NormalizeFilterOperator(c.FilterOperator) + if len(c.Values) == 0 { + return false, nil + } + + switch attr { + case "content": + return matchTextCondition(message.Content, c.Values, op), nil + case "private_note": + return matchBoolCondition(message.Private, c.Values, op), nil + case "message_type": + return matchMessageTypeCondition(message.MessageType, c.Values, op), nil + default: + return false, fmt.Errorf("unsupported message condition attribute: %s", attr) + } +} + +func matchTextCondition(actual string, values []string, op string) bool { + actual = strings.ToLower(actual) + matches := func(value string) bool { return actual == strings.ToLower(value) } + contains := func(value string) bool { return strings.Contains(actual, strings.ToLower(value)) } + switch op { + case "equal": + return anyValue(values, matches) + case "not_equal": + return !anyValue(values, matches) + case "contains": + return anyValue(values, contains) + case "does_not_contain": + return !anyValue(values, contains) + default: + return false + } +} + +func matchBoolCondition(actual bool, values []string, op string) bool { + matches := func(value string) bool { + parsed, ok := parseBoolLike(value) + return ok && parsed == actual + } + switch op { + case "equal": + return anyValue(values, matches) + case "not_equal": + return !anyValue(values, matches) + default: + return false + } +} + +func matchMessageTypeCondition(actual string, values []string, op string) bool { + actual = normalizeMessageTypeForCondition(actual) + matches := func(value string) bool { return actual == normalizeMessageTypeForCondition(value) } + switch op { + case "equal": + return anyValue(values, matches) + case "not_equal": + return !anyValue(values, matches) + default: + return false + } +} + +func anyValue(values []string, fn func(string) bool) bool { + for _, value := range values { + if fn(value) { + return true + } + } + return false +} + +func parseBoolLike(value string) (bool, bool) { + switch strings.ToLower(strings.TrimSpace(value)) { + case "true", "1", "t", "yes": + return true, true + case "false", "0", "f", "no": + return false, true + default: + parsed, err := strconv.ParseBool(value) + return parsed, err == nil + } +} + +func normalizeMessageTypeForCondition(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "0", "incoming": + return string(model.MessageTypeIncoming) + case "1", "outgoing": + return string(model.MessageTypeOutgoing) + case "2", "activity": + return string(model.MessageTypeActivity) + case "3", "template": + return string(model.MessageTypeTemplate) + default: + return strings.ToLower(strings.TrimSpace(value)) + } +} + +func messageDataForConditions(ctx context.Context, db *gorm.DB, conv *ConversationForFilter, eventData map[string]interface{}) (messageConditionData, bool) { + if message, ok := extractMessageFromData(eventData); ok && message != nil { + return messageConditionDataFromModel(message), true + } + if message, ok := messageConditionDataFromMap(eventData); ok { + return message, true + } + if messageID, ok := extractUintFromData(eventData, "message_id"); ok && messageID != 0 && db != nil { + var message model.Message + query := db.WithContext(ctx).Where("id = ?", messageID) + if conv != nil && conv.ID != 0 { + query = query.Where("conversation_id = ?", conv.ID) + } + if err := query.First(&message).Error; err == nil { + return messageConditionDataFromModel(&message), true + } + } + return messageConditionData{}, false +} + +func messageConditionDataFromModel(message *model.Message) messageConditionData { + content := message.Content + attrs := listenerJSONMap(message.ContentAttributes) + if processed, ok := attrs["processed_message_content"].(string); ok && processed != "" { + content = processed + } + return messageConditionData{Content: content, MessageType: message.MessageType, Private: message.Private} +} + +func messageConditionDataFromMap(data map[string]interface{}) (messageConditionData, bool) { + if data == nil { + return messageConditionData{}, false + } + raw := data + if nested, ok := data["message"].(map[string]interface{}); ok { + raw = nested + } + content := firstStringValue(raw, "processed_message_content", "content") + messageType := firstStringValue(raw, "message_type") + private, hasPrivate := firstBoolValue(raw, "private", "private_note") + if content == "" && messageType == "" && !hasPrivate { + return messageConditionData{}, false + } + return messageConditionData{Content: content, MessageType: messageType, Private: private}, true +} + +func firstStringValue(values map[string]interface{}, keys ...string) string { + for _, key := range keys { + if value, ok := values[key]; ok && value != nil { + switch v := value.(type) { + case string: + return v + case fmt.Stringer: + return v.String() + default: + return fmt.Sprintf("%v", v) + } + } + } + return "" +} + +func firstBoolValue(values map[string]interface{}, keys ...string) (bool, bool) { + for _, key := range keys { + if value, ok := values[key]; ok && value != nil { + switch v := value.(type) { + case bool: + return v, true + case string: + return parseBoolLike(v) + case int: + return v != 0, true + case uint: + return v != 0, true + case float64: + return v != 0, true + default: + return false, false + } + } + } + return false, false +} + // matchDirectConditions evaluates conditions that directly check conversation attributes. // Reference: Chatwoot ConditionsFilterService — builds SQL WHERE clauses from conditions func matchDirectConditions(db *gorm.DB, conditions Conditions, conv *ConversationForFilter) (bool, error) { diff --git a/internal/automation/condition_filter_test.go b/internal/automation/condition_filter_test.go index 74e9c66a..5d9ffe37 100644 --- a/internal/automation/condition_filter_test.go +++ b/internal/automation/condition_filter_test.go @@ -3,6 +3,9 @@ package automation import ( "context" "testing" + + "github.com/gochat/gochat/internal/model" + "gorm.io/datatypes" ) // =========================== @@ -238,6 +241,102 @@ func TestMatchConditions_MixedDirectAndChanged(t *testing.T) { } } +func TestMatchConditions_MessageCreatedMessageFilters(t *testing.T) { + dbProvider := setupAutomationTestDBProvider(t) + accountID, _ := seedTestAccount(dbProvider.DB(), t) + inboxID := seedTestInbox(dbProvider.DB(), t, accountID) + contactID := seedTestContact(dbProvider.DB(), t, accountID) + convID := seedTestConversationWithDetails(dbProvider.DB(), t, accountID, inboxID, contactID, "open", "", "web", 0) + conv := &ConversationForFilter{ID: convID, AccountID: accountID, InboxID: inboxID, ContactID: contactID, Status: "open"} + + eventData := map[string]interface{}{ + "message": &model.Message{ + AccountID: accountID, + InboxID: inboxID, + ConversationID: convID, + Content: "Original markdown", + ContentAttributes: datatypes.JSON([]byte(`{"processed_message_content":"Need Help With Billing"}`)), + MessageType: "incoming", + Private: false, + }, + } + + conditions := Conditions{ + {Attribute: "message_type", FilterOperator: "equal_to", Values: []string{"0"}, QueryOperator: "and"}, + {Attribute: "private_note", FilterOperator: "equal_to", Values: []string{"false"}, QueryOperator: "and"}, + {Attribute: "content", FilterOperator: "contains", Values: []string{"billing"}, QueryOperator: "and"}, + } + + matched, err := MatchConditions(context.Background(), dbProvider.DB(), conditions, conv, eventData) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if !matched { + t.Fatal("expected Chatwoot message filters to match triggering message") + } +} + +func TestMatchConditions_MessageFilterRejectsNonMatchingMessage(t *testing.T) { + dbProvider := setupAutomationTestDBProvider(t) + accountID, _ := seedTestAccount(dbProvider.DB(), t) + inboxID := seedTestInbox(dbProvider.DB(), t, accountID) + contactID := seedTestContact(dbProvider.DB(), t, accountID) + convID := seedTestConversationWithDetails(dbProvider.DB(), t, accountID, inboxID, contactID, "open", "", "web", 0) + conv := &ConversationForFilter{ID: convID, AccountID: accountID, InboxID: inboxID, ContactID: contactID, Status: "open"} + + eventData := map[string]interface{}{ + "message": &model.Message{AccountID: accountID, InboxID: inboxID, ConversationID: convID, Content: "Need help", MessageType: "outgoing", Private: true}, + } + conditions := Conditions{{Attribute: "message_type", FilterOperator: "equal_to", Values: []string{"incoming"}}} + + matched, err := MatchConditions(context.Background(), dbProvider.DB(), conditions, conv, eventData) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if matched { + t.Fatal("expected message_type condition not to match outgoing message") + } +} + +func TestMatchConditions_MessageFiltersSupportOrGroup(t *testing.T) { + dbProvider := setupAutomationTestDBProvider(t) + accountID, _ := seedTestAccount(dbProvider.DB(), t) + inboxID := seedTestInbox(dbProvider.DB(), t, accountID) + contactID := seedTestContact(dbProvider.DB(), t, accountID) + convID := seedTestConversationWithDetails(dbProvider.DB(), t, accountID, inboxID, contactID, "open", "", "web", 0) + conv := &ConversationForFilter{ID: convID, AccountID: accountID, InboxID: inboxID, ContactID: contactID, Status: "open"} + + eventData := map[string]interface{}{ + "message": map[string]interface{}{"content": "Need billing help", "message_type": "outgoing", "private": false}, + } + conditions := Conditions{ + {Attribute: "content", FilterOperator: "contains", Values: []string{"pricing"}, QueryOperator: "or"}, + {Attribute: "message_type", FilterOperator: "equal_to", Values: []string{"1"}, QueryOperator: "or"}, + } + + matched, err := MatchConditions(context.Background(), dbProvider.DB(), conditions, conv, eventData) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if !matched { + t.Fatal("expected OR message filter group to match outgoing message_type") + } +} + +func TestMatchConditions_MessageFilterWithoutMessageDoesNotMatch(t *testing.T) { + dbProvider := setupAutomationTestDBProvider(t) + conv := &ConversationForFilter{ID: 1, Status: "open"} + conditions := Conditions{{Attribute: "content", FilterOperator: "contains", Values: []string{"help"}}} + + matched, err := MatchConditions(context.Background(), dbProvider.DB(), conditions, conv, map[string]interface{}{}) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if matched { + t.Fatal("expected message condition without triggering message data not to match") + } +} + // =========================== // buildConditionClause tests (pure logic, no DB) // =========================== @@ -541,4 +640,4 @@ func TestNormalizeEventName(t *testing.T) { t.Fatalf("normalizeEventName(%q) = %q, expected %q", tt.input, result, tt.expected) } } -} \ No newline at end of file +} diff --git a/internal/automation/condition_validator.go b/internal/automation/condition_validator.go index 3060d234..0bb1a389 100644 --- a/internal/automation/condition_validator.go +++ b/internal/automation/condition_validator.go @@ -157,6 +157,10 @@ func ValidateConditions(conditions Conditions) error { } } + if err := validateAttributeOperator(c.Attribute, filterOperator); err != nil { + return fmt.Errorf("condition[%d]: %w", i, err) + } + // is_present / is_not_present should not need values if filterOperator == "is_present" || filterOperator == "is_not_present" { // Values are optional for presence checks — not enforced @@ -168,6 +172,20 @@ func ValidateConditions(conditions Conditions) error { return nil } +func validateAttributeOperator(attribute string, operator string) error { + switch attribute { + case "message_type", "private_note": + if operator != "equal" && operator != "not_equal" { + return fmt.Errorf("attribute '%s' does not support filter_operator '%s'", attribute, operator) + } + case "content": + if operator != "equal" && operator != "not_equal" && operator != "contains" && operator != "does_not_contain" { + return fmt.Errorf("attribute 'content' does not support filter_operator '%s'", operator) + } + } + return nil +} + // ValidateActions checks that all actions use valid action types. // Reference: Chatwoot AutomationRule action_name validation func ValidateActions(actions Actions) error { diff --git a/internal/automation/condition_validator_test.go b/internal/automation/condition_validator_test.go index df40f541..2632e519 100644 --- a/internal/automation/condition_validator_test.go +++ b/internal/automation/condition_validator_test.go @@ -183,6 +183,30 @@ func TestValidateConditions_ContainsOperator(t *testing.T) { } } +func TestValidateConditions_ChatwootMessageFilterOperators(t *testing.T) { + valid := Conditions{ + {Attribute: "message_type", FilterOperator: "equal_to", Values: []string{"0"}}, + {Attribute: "private_note", FilterOperator: "not_equal_to", Values: []string{"true"}}, + {Attribute: "content", FilterOperator: "does_not_contain", Values: []string{"spam"}}, + } + if err := ValidateConditions(valid); err != nil { + t.Fatalf("expected Chatwoot message filter operators to validate, got: %v", err) + } +} + +func TestValidateConditions_RejectsUnsupportedMessageFilterOperators(t *testing.T) { + tests := []Condition{ + {Attribute: "message_type", FilterOperator: "contains", Values: []string{"incoming"}}, + {Attribute: "private_note", FilterOperator: "contains", Values: []string{"true"}}, + {Attribute: "content", FilterOperator: "is_present", Values: []string{}}, + } + for _, condition := range tests { + if err := ValidateConditions(Conditions{condition}); err == nil { + t.Fatalf("expected invalid operator for %s/%s", condition.Attribute, condition.FilterOperator) + } + } +} + func TestValidateConditions_AttributeChangedOperator(t *testing.T) { conditions := Conditions{ { @@ -211,4 +235,4 @@ func TestValidateConditions_DoesNotContainOperator(t *testing.T) { if err != nil { t.Fatalf("expected no error for does_not_contain operator, got: %v", err) } -} \ No newline at end of file +} diff --git a/internal/automation/listener_test.go b/internal/automation/listener_test.go index bf7af2f1..4bad8c0e 100644 --- a/internal/automation/listener_test.go +++ b/internal/automation/listener_test.go @@ -303,6 +303,55 @@ func TestAutomationRuleListener_OnEvent_DispatchesAllChatwootReferenceEvents(t * } } +func TestAutomationRuleListener_OnEvent_MessageCreatedAppliesMessageFilters(t *testing.T) { + dbProvider := setupAutomationTestDBProvider(t) + db := dbProvider.DB() + + accountID, _ := seedTestAccount(db, t) + inboxID := seedTestInbox(db, t, accountID) + contactID := seedTestContact(db, t, accountID) + conversationID := seedTestConversation(db, t, accountID, inboxID, contactID) + + matchingRule := &AutomationRule{ + AccountID: accountID, + EventName: "message_created", + Name: "incoming billing message", + Conditions: Conditions{ + {Attribute: "message_type", FilterOperator: "equal_to", Values: []string{"0"}, QueryOperator: "and"}, + {Attribute: "private_note", FilterOperator: "equal_to", Values: []string{"false"}, QueryOperator: "and"}, + {Attribute: "content", FilterOperator: "contains", Values: []string{"billing"}, QueryOperator: "and"}, + }, + Actions: Actions{}, + Active: true, + } + nonMatchingRule := &AutomationRule{ + AccountID: accountID, + EventName: "message_created", + Name: "private note only", + Conditions: Conditions{ + {Attribute: "private_note", FilterOperator: "equal_to", Values: []string{"true"}}, + }, + Actions: Actions{}, + Active: true, + } + ruleSvc := NewAutomationRuleService(dbProvider) + require.NoError(t, ruleSvc.Create(context.Background(), matchingRule)) + require.NoError(t, ruleSvc.Create(context.Background(), nonMatchingRule)) + + message := &model.Message{AccountID: accountID, InboxID: inboxID, ConversationID: conversationID, MessageType: "incoming", ContentType: "text", Content: "I need Billing help", Private: false} + event := &channel.ChannelEvent{Type: channel.EventMessageCreated, AccountID: accountID, InboxID: inboxID, Data: map[string]interface{}{"message": message}} + + require.NoError(t, NewAutomationRuleListener(dbProvider).OnEvent(context.Background(), event)) + + var matchedCount int64 + require.NoError(t, db.Model(&AutomationExecution{}).Where("rule_id = ? AND status = ?", matchingRule.ID, ExecutionStatusSuccess).Count(&matchedCount).Error) + assert.Equal(t, int64(1), matchedCount) + + var skippedCount int64 + require.NoError(t, db.Model(&AutomationExecution{}).Where("rule_id = ? AND status = ?", nonMatchingRule.ID, ExecutionStatusSkipped).Count(&skippedCount).Error) + assert.Equal(t, int64(1), skippedCount) +} + func TestAutomationRuleListener_OnEvent_UsesChangedAttributesForConditions(t *testing.T) { dbProvider := setupAutomationTestDBProvider(t) db := dbProvider.DB()