Files
gochat/docs/requirements/M06-codegraph-context.md
T
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

16 KiB

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
  • 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)

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)

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)

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)

  def conversation_updated(event)
    process_conversation_event(event, 'conversation_updated')
  end

conversation_created (app/listeners/automation_rule_listener.rb:6)

  def conversation_created(event)
    process_conversation_event(event, 'conversation_created')
  end

conversation_opened (app/listeners/automation_rule_listener.rb:10)

  def conversation_opened(event)
    process_conversation_event(event, 'conversation_opened')
  end

conversation_resolved (app/listeners/automation_rule_listener.rb:14)

  def conversation_resolved(event)
    process_conversation_event(event, 'conversation_resolved')
  end

message_created (app/listeners/automation_rule_listener.rb:18)

  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)

  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)

  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)

  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)

  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)

  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)

  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)

  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)

  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)

  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)

  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)

  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)

  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)

  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)

  def query_operator_value
    conditions.each do |obj|
      validate_single_condition(obj)
    end
  end

validate_single_condition (app/models/automation_rule.rb:98)

  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)

  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)

  constructor() {
    super('canned_responses', { accountScoped: true });
  }

get (app/javascript/dashboard/api/cannedResponse.js:10)

  get({ searchKey }) {
    const url = searchKey ? `${this.url}?search=${searchKey}` : this.url;
    return axios.get(url);
  }

BaseListener (app/listeners/base_listener.rb:1)

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)

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)

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