Files
gochat/docs/requirements/M06-codegraph-context.md
rogee 0dabb8cfa5 docs: 整理文档目录结构 — 清理过时文档、归集功能子目录、统一命名规范
清理:
- 删除 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 相关修改
2026-07-09 14:53:27 +08:00

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}`);
}
}
```