清理: - 删除 34 份过时文档(gap reports/QA临时报告/验收报告/阶段性文档) - 删除 docs/.hermes/skills 第三方 skills 副本(16 文件) - 删除 skills-lock.json 目录归集: - 根目录仅保留 README.md 索引 - product/ — 产品与架构设计(PRD + ARCHITECTURE + P2设计文档 + AI/企业路线图) - tracking/ — Chatwoot parity 开发跟踪 - requirements/ — M01-M12 模块需求 - plans/ — 历史实现计划 - parity/ — 路由 parity 与前端契约 - qa/ — QA 报告与测试计划 - ops/ — 运维部署 命名规范: - 全小写 kebab-case,禁止全大写文件名 - product/tracking/ops 用 NN- 序号前缀 - requirements 用 MNN- 两位零填充模块号 - plans/qa 用 YYYY-MM-DD- 日期前缀 - requirements M1-M9 零填充为 M01-M09(修复字典序) 同步更新: - backend/cmd/route_parity/main.go 路径默认值 - backend/scripts/parity_frontend_smoke.sh 报告路径 - 所有 docs 内部交叉引用 - .gitignore 排除编译产物 (backend/gochat, backend/route_parity) - 新增迁移 000052/000053 - 前端 WS 相关修改
574 lines
16 KiB
Markdown
574 lines
16 KiB
Markdown
## Code Context
|
|
|
|
**Query:** automation rule, macro, canned response, automation actions, automation conditions, automation listener, automation execution
|
|
|
|
### Entry Points
|
|
|
|
- **AutomationRuleListener** (class) - app/listeners/automation_rule_listener.rb:1
|
|
- **AutomationRule** (class) - app/models/automation_rule.rb:20
|
|
- **CannedResponse** (class) - app/javascript/dashboard/api/cannedResponse.js:5
|
|
|
|
### Related Symbols
|
|
|
|
- app/listeners/base_listener.rb: BaseListener:1
|
|
- app/models/application_record.rb: ApplicationRecord:1
|
|
- app/javascript/dashboard/api/ApiClient.js: ApiClient:5
|
|
- app/listeners/automation_rule_listener.rb: conversation_updated:2, conversation_created:6, conversation_opened:10, conversation_resolved:14, message_created:18, process_conversation_event:39, rule_present?:59
|
|
|
|
### Code
|
|
|
|
#### AutomationRuleListener (app/listeners/automation_rule_listener.rb:1)
|
|
|
|
```ruby
|
|
class AutomationRuleListener < BaseListener
|
|
def conversation_updated(event)
|
|
process_conversation_event(event, 'conversation_updated')
|
|
end
|
|
|
|
def conversation_created(event)
|
|
process_conversation_event(event, 'conversation_created')
|
|
end
|
|
|
|
def conversation_opened(event)
|
|
process_conversation_event(event, 'conversation_opened')
|
|
end
|
|
|
|
def conversation_resolved(event)
|
|
process_conversation_event(event, 'conversation_resolved')
|
|
end
|
|
|
|
def message_created(event)
|
|
message = event.data[:message]
|
|
|
|
return if ignore_message_created_event?(event)
|
|
|
|
account = message.try(:account)
|
|
changed_attributes = event.data[:changed_attributes]
|
|
|
|
return unless rule_present?('message_created', account)
|
|
|
|
rules = current_account_rules('message_created', account)
|
|
|
|
rules.each do |rule|
|
|
conditions_match = ::AutomationRules::ConditionsFilterService.new(rule, message.conversation,
|
|
{ message: message, changed_attributes: changed_attributes }).perform
|
|
::AutomationRules::ActionService.new(rule, account, message.conversation).perform if conditions_match.present?
|
|
end
|
|
end
|
|
|
|
private
|
|
|
|
def process_conversation_event(event, event_name)
|
|
return if performed_by_automation?(event)
|
|
|
|
auto_reply_skip_events = %w[conversation_created conversation_opened]
|
|
return if auto_reply_skip_events.include?(event_name) && ignore_auto_reply_event?(event)
|
|
|
|
conversation = event.data[:c
|
|
// ... truncated ...
|
|
```
|
|
|
|
#### AutomationRule (app/models/automation_rule.rb:20)
|
|
|
|
```ruby
|
|
class AutomationRule < ApplicationRecord
|
|
include Rails.application.routes.url_helpers
|
|
include Reauthorizable
|
|
|
|
belongs_to :account
|
|
has_many_attached :files
|
|
|
|
validate :json_conditions_format
|
|
validate :json_actions_format
|
|
validate :query_operator_presence
|
|
validate :query_operator_value
|
|
validates :account_id, presence: true
|
|
|
|
after_update_commit :reauthorized!, if: -> { saved_change_to_conditions? }
|
|
|
|
scope :active, -> { where(active: true) }
|
|
|
|
def conditions_attributes
|
|
%w[content email country_code status message_type browser_language assignee_id team_id referer city company_name inbox_id
|
|
mail_subject phone_number priority conversation_language labels private_note]
|
|
end
|
|
|
|
def actions_attributes
|
|
%w[send_message add_label remove_label send_email_to_team assign_team assign_agent remove_assigned_agent
|
|
remove_assigned_team send_webhook_event mute_conversation send_attachment change_status resolve_conversation
|
|
open_conversation pending_conversation snooze_conversation change_priority send_email_transcript
|
|
add_private_note].freeze
|
|
end
|
|
|
|
def file_base_data
|
|
files.map do |file|
|
|
{
|
|
id: file.id,
|
|
automation_rule_id: id,
|
|
file_type: file.content_type,
|
|
account_id: account_id,
|
|
file_url: url_for(file),
|
|
blob_id: file.blob_id,
|
|
filename: file.filename.to_s
|
|
}
|
|
end
|
|
end
|
|
|
|
private
|
|
|
|
def json_conditions_format
|
|
return if conditions.blank?
|
|
|
|
attributes = conditi
|
|
// ... truncated ...
|
|
```
|
|
|
|
#### CannedResponse (app/javascript/dashboard/api/cannedResponse.js:5)
|
|
|
|
```javascript
|
|
class CannedResponse extends ApiClient {
|
|
constructor() {
|
|
super('canned_responses', { accountScoped: true });
|
|
}
|
|
|
|
get({ searchKey }) {
|
|
const url = searchKey ? `${this.url}?search=${searchKey}` : this.url;
|
|
return axios.get(url);
|
|
}
|
|
}
|
|
```
|
|
|
|
#### conversation_updated (app/listeners/automation_rule_listener.rb:2)
|
|
|
|
```ruby
|
|
def conversation_updated(event)
|
|
process_conversation_event(event, 'conversation_updated')
|
|
end
|
|
```
|
|
|
|
#### conversation_created (app/listeners/automation_rule_listener.rb:6)
|
|
|
|
```ruby
|
|
def conversation_created(event)
|
|
process_conversation_event(event, 'conversation_created')
|
|
end
|
|
```
|
|
|
|
#### conversation_opened (app/listeners/automation_rule_listener.rb:10)
|
|
|
|
```ruby
|
|
def conversation_opened(event)
|
|
process_conversation_event(event, 'conversation_opened')
|
|
end
|
|
```
|
|
|
|
#### conversation_resolved (app/listeners/automation_rule_listener.rb:14)
|
|
|
|
```ruby
|
|
def conversation_resolved(event)
|
|
process_conversation_event(event, 'conversation_resolved')
|
|
end
|
|
```
|
|
|
|
#### message_created (app/listeners/automation_rule_listener.rb:18)
|
|
|
|
```ruby
|
|
def message_created(event)
|
|
message = event.data[:message]
|
|
|
|
return if ignore_message_created_event?(event)
|
|
|
|
account = message.try(:account)
|
|
changed_attributes = event.data[:changed_attributes]
|
|
|
|
return unless rule_present?('message_created', account)
|
|
|
|
rules = current_account_rules('message_created', account)
|
|
|
|
rules.each do |rule|
|
|
conditions_match = ::AutomationRules::ConditionsFilterService.new(rule, message.conversation,
|
|
{ message: message, changed_attributes: changed_attributes }).perform
|
|
::AutomationRules::ActionService.new(rule, account, message.conversation).perform if conditions_match.present?
|
|
end
|
|
end
|
|
```
|
|
|
|
#### process_conversation_event (app/listeners/automation_rule_listener.rb:39)
|
|
|
|
```ruby
|
|
def process_conversation_event(event, event_name)
|
|
return if performed_by_automation?(event)
|
|
|
|
auto_reply_skip_events = %w[conversation_created conversation_opened]
|
|
return if auto_reply_skip_events.include?(event_name) && ignore_auto_reply_event?(event)
|
|
|
|
conversation = event.data[:conversation]
|
|
account = conversation.account
|
|
changed_attributes = event.data[:changed_attributes]
|
|
|
|
return unless rule_present?(event_name, account)
|
|
|
|
rules = current_account_rules(event_name, account)
|
|
|
|
rules.each do |rule|
|
|
conditions_match = ::AutomationRules::ConditionsFilterService.new(rule, conversation, { changed_attributes: changed_attributes }).perform
|
|
AutomationRules::ActionService.new(rule, account, conversation).perform if conditions_match.present?
|
|
end
|
|
end
|
|
```
|
|
|
|
#### rule_present? (app/listeners/automation_rule_listener.rb:59)
|
|
|
|
```ruby
|
|
def rule_present?(event_name, account)
|
|
return if account.blank?
|
|
|
|
current_account_rules(event_name, account).any?
|
|
end
|
|
```
|
|
|
|
#### current_account_rules (app/listeners/automation_rule_listener.rb:65)
|
|
|
|
```ruby
|
|
def current_account_rules(event_name, account)
|
|
AutomationRule.where(
|
|
event_name: event_name,
|
|
account_id: account.id,
|
|
active: true
|
|
)
|
|
end
|
|
```
|
|
|
|
#### performed_by_automation? (app/listeners/automation_rule_listener.rb:73)
|
|
|
|
```ruby
|
|
def performed_by_automation?(event)
|
|
event.data[:performed_by].present? && event.data[:performed_by].instance_of?(AutomationRule)
|
|
end
|
|
```
|
|
|
|
#### ignore_auto_reply_event? (app/listeners/automation_rule_listener.rb:77)
|
|
|
|
```ruby
|
|
def ignore_auto_reply_event?(event)
|
|
conversation = event.data[:conversation]
|
|
conversation.additional_attributes['auto_reply'].present?
|
|
end
|
|
```
|
|
|
|
#### ignore_message_created_event? (app/listeners/automation_rule_listener.rb:82)
|
|
|
|
```ruby
|
|
def ignore_message_created_event?(event)
|
|
message = event.data[:message]
|
|
performed_by_automation?(event) || message.activity? || message.auto_reply_email?
|
|
end
|
|
```
|
|
|
|
#### listeners (app/dispatchers/async_dispatcher.rb:11)
|
|
|
|
```ruby
|
|
def listeners
|
|
[
|
|
AutomationRuleListener.instance,
|
|
CampaignListener.instance,
|
|
CsatSurveyListener.instance,
|
|
HookListener.instance,
|
|
InstallationWebhookListener.instance,
|
|
NotificationListener.instance,
|
|
ParticipationListener.instance,
|
|
Conversations::UnreadCounts::Listener.instance,
|
|
ReportingEventListener.instance,
|
|
WebhookListener.instance
|
|
]
|
|
end
|
|
```
|
|
|
|
#### conditions_attributes (app/models/automation_rule.rb:37)
|
|
|
|
```ruby
|
|
def conditions_attributes
|
|
%w[content email country_code status message_type browser_language assignee_id team_id referer city company_name inbox_id
|
|
mail_subject phone_number priority conversation_language labels private_note]
|
|
end
|
|
```
|
|
|
|
#### actions_attributes (app/models/automation_rule.rb:42)
|
|
|
|
```ruby
|
|
def actions_attributes
|
|
%w[send_message add_label remove_label send_email_to_team assign_team assign_agent remove_assigned_agent
|
|
remove_assigned_team send_webhook_event mute_conversation send_attachment change_status resolve_conversation
|
|
open_conversation pending_conversation snooze_conversation change_priority send_email_transcript
|
|
add_private_note].freeze
|
|
end
|
|
```
|
|
|
|
#### file_base_data (app/models/automation_rule.rb:49)
|
|
|
|
```ruby
|
|
def file_base_data
|
|
files.map do |file|
|
|
{
|
|
id: file.id,
|
|
automation_rule_id: id,
|
|
file_type: file.content_type,
|
|
account_id: account_id,
|
|
file_url: url_for(file),
|
|
blob_id: file.blob_id,
|
|
filename: file.filename.to_s
|
|
}
|
|
end
|
|
end
|
|
```
|
|
|
|
#### json_conditions_format (app/models/automation_rule.rb:65)
|
|
|
|
```ruby
|
|
def json_conditions_format
|
|
return if conditions.blank?
|
|
|
|
attributes = conditions.map { |obj, _| obj['attribute_key'] }
|
|
conditions = attributes - conditions_attributes
|
|
conditions -= account.custom_attribute_definitions.pluck(:attribute_key)
|
|
errors.add(:conditions, "Automation conditions #{conditions.join(',')} not supported.") if conditions.any?
|
|
end
|
|
```
|
|
|
|
#### json_actions_format (app/models/automation_rule.rb:74)
|
|
|
|
```ruby
|
|
def json_actions_format
|
|
return if actions.blank?
|
|
|
|
attributes = actions.map { |obj, _| obj['action_name'] }
|
|
actions = attributes - actions_attributes
|
|
|
|
errors.add(:actions, "Automation actions #{actions.join(',')} not supported.") if actions.any?
|
|
end
|
|
```
|
|
|
|
#### query_operator_presence (app/models/automation_rule.rb:83)
|
|
|
|
```ruby
|
|
def query_operator_presence
|
|
return if conditions.blank?
|
|
|
|
operators = conditions.select { |obj, _| obj['query_operator'].nil? }
|
|
errors.add(:conditions, 'Automation conditions should have query operator.') if operators.length > 1
|
|
end
|
|
```
|
|
|
|
#### query_operator_value (app/models/automation_rule.rb:92)
|
|
|
|
```ruby
|
|
def query_operator_value
|
|
conditions.each do |obj|
|
|
validate_single_condition(obj)
|
|
end
|
|
end
|
|
```
|
|
|
|
#### validate_single_condition (app/models/automation_rule.rb:98)
|
|
|
|
```ruby
|
|
def validate_single_condition(condition)
|
|
query_operator = condition['query_operator']
|
|
|
|
return if query_operator.nil?
|
|
return if query_operator.empty?
|
|
|
|
operator = query_operator.upcase
|
|
errors.add(:conditions, 'Query operator must be either "AND" or "OR"') unless %w[AND OR].include?(operator)
|
|
end
|
|
```
|
|
|
|
#### migrate_automation_rule_conditions (db/migrate/20260427094500_rename_company_condition_key_in_automation_rules.rb:11)
|
|
|
|
```ruby
|
|
def migrate_automation_rule_conditions
|
|
AutomationRule.find_each do |rule|
|
|
conditions = rename_company_attribute_key(rule.conditions)
|
|
|
|
next if conditions == rule.conditions
|
|
|
|
rule.update_column(:conditions, conditions) # rubocop:disable Rails/SkipsModelValidations
|
|
end
|
|
end
|
|
```
|
|
|
|
#### constructor (app/javascript/dashboard/api/cannedResponse.js:6)
|
|
|
|
```javascript
|
|
constructor() {
|
|
super('canned_responses', { accountScoped: true });
|
|
}
|
|
```
|
|
|
|
#### get (app/javascript/dashboard/api/cannedResponse.js:10)
|
|
|
|
```javascript
|
|
get({ searchKey }) {
|
|
const url = searchKey ? `${this.url}?search=${searchKey}` : this.url;
|
|
return axios.get(url);
|
|
}
|
|
```
|
|
|
|
#### BaseListener (app/listeners/base_listener.rb:1)
|
|
|
|
```ruby
|
|
class BaseListener
|
|
include Singleton
|
|
|
|
def extract_conversation_and_account(event)
|
|
conversation = event.data[:conversation]
|
|
[conversation, conversation.account]
|
|
end
|
|
|
|
def extract_notification_and_account(event)
|
|
notification = event.data[:notification]
|
|
notification_finder = NotificationFinder.new(notification.user, notification.account)
|
|
unread_count = notification_finder.unread_count
|
|
count = notification_finder.count
|
|
[notification, notification.account, unread_count, count]
|
|
end
|
|
|
|
def extract_message_and_account(event)
|
|
message = event.data[:message]
|
|
[message, message.account]
|
|
end
|
|
|
|
def extract_contact_and_account(event)
|
|
contact = event.data[:contact]
|
|
[contact, contact.account]
|
|
end
|
|
|
|
def extract_inbox_and_account(event)
|
|
inbox = event.data[:inbox]
|
|
[inbox, inbox.account]
|
|
end
|
|
|
|
def extract_changed_attributes(event)
|
|
changed_attributes = event.data[:changed_attributes]
|
|
|
|
return if changed_attributes.blank?
|
|
|
|
changed_attributes.map { |k, v| { k => { previous_value: v[0], current_value: v[1] } } }
|
|
end
|
|
end
|
|
```
|
|
|
|
#### ApplicationRecord (app/models/application_record.rb:1)
|
|
|
|
```ruby
|
|
class ApplicationRecord < ActiveRecord::Base
|
|
include Events::Types
|
|
self.abstract_class = true
|
|
|
|
before_validation :validates_column_content_length
|
|
|
|
# the models that exposed in email templates through liquid
|
|
def droppables
|
|
%w[Account Channel Conversation Inbox User Message]
|
|
end
|
|
|
|
# ModelDrop class should exist in app/drops
|
|
def to_drop
|
|
return unless droppables.include?(self.class.name)
|
|
|
|
"#{self.class.name}Drop".constantize.new(self)
|
|
end
|
|
|
|
private
|
|
|
|
# Generic validation for all columns of type string and text
|
|
# Validates the length of the column to prevent DOS via large payloads
|
|
# if a custom length validation is already present, skip the validation
|
|
def validates_column_content_length
|
|
self.class.columns.each do |column|
|
|
check_and_validate_content_length(column) if column_of_type_string_or_text?(column)
|
|
end
|
|
end
|
|
|
|
def column_of_type_string_or_text?(column)
|
|
%i[string text].include?(column.type)
|
|
end
|
|
|
|
def check_and_validate_content_length(column)
|
|
length_validator = self.class.validators_on(column.name).find { |v| v.kind == :length }
|
|
validate_content_length(column) if length_validator.blank?
|
|
end
|
|
|
|
def validate_content_length(column)
|
|
max_length = column.type == :text ? 20_000 : 255
|
|
return if self[column.name].nil? || self[column.name].length <= max_length
|
|
|
|
errors.add(column.name.to_sym, "is too long (maximum is #{max_length} characters)")
|
|
end
|
|
|
|
def normalize_empty_string_to_nil(attrs = [])
|
|
attrs
|
|
// ... truncated ...
|
|
```
|
|
|
|
#### ApiClient (app/javascript/dashboard/api/ApiClient.js:5)
|
|
|
|
```javascript
|
|
class ApiClient {
|
|
constructor(resource, options = {}) {
|
|
this.apiVersion = `/api/${options.apiVersion || DEFAULT_API_VERSION}`;
|
|
this.options = options;
|
|
this.resource = resource;
|
|
}
|
|
|
|
get url() {
|
|
return `${this.baseUrl()}/${this.resource}`;
|
|
}
|
|
|
|
// eslint-disable-next-line class-methods-use-this
|
|
get accountIdFromRoute() {
|
|
const isInsideAccountScopedURLs =
|
|
window.location.pathname.includes('/app/accounts');
|
|
|
|
if (isInsideAccountScopedURLs) {
|
|
return window.location.pathname.split('/')[3];
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
baseUrl() {
|
|
let url = this.apiVersion;
|
|
|
|
if (this.options.enterprise) {
|
|
url = `/enterprise${url}`;
|
|
}
|
|
|
|
if (this.options.accountScoped && this.accountIdFromRoute) {
|
|
url = `${url}/accounts/${this.accountIdFromRoute}`;
|
|
}
|
|
|
|
return url;
|
|
}
|
|
|
|
get() {
|
|
return axios.get(this.url);
|
|
}
|
|
|
|
show(id) {
|
|
return axios.get(`${this.url}/${id}`);
|
|
}
|
|
|
|
create(data) {
|
|
return axios.post(this.url, data);
|
|
}
|
|
|
|
update(id, data) {
|
|
return axios.patch(`${this.url}/${id}`, data);
|
|
}
|
|
|
|
delete(id) {
|
|
return axios.delete(`${this.url}/${id}`);
|
|
}
|
|
}
|
|
``` |