Files
gochat/docs/requirements/M03-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

14 KiB

Code Context

Query: conversation, message, conversation participant, attachment, message template, incoming message service, outgoing message service, conversation status, message type

Entry Points

  • Sms::IncomingMessageService (class) - app/services/sms/incoming_message_service.rb:1
  • Line::IncomingMessageService (class) - app/services/line/incoming_message_service.rb:4
  • Twilio::IncomingMessageService (class) - app/services/twilio/incoming_message_service.rb:1
  • app/services/sms/incoming_message_service.rb: perform:6, account:23, channel:27, phone_number:31, formatted_phone_number:35, set_contact:39, conversation_params:50, set_conversation:59, contact_attributes:72, attach_files:79

Code

Sms::IncomingMessageService (app/services/sms/incoming_message_service.rb:1)

class Sms::IncomingMessageService
  include ::FileTypeHelper

  pattr_initialize [:inbox!, :params!]

  def perform
    set_contact
    set_conversation
    @message = @conversation.messages.create!(
      content: params[:text],
      account_id: @inbox.account_id,
      inbox_id: @inbox.id,
      message_type: :incoming,
      sender: @contact,
      source_id: params[:id]
    )
    attach_files
    @message.save!
  end

  private

  def account
    @account ||= @inbox.account
  end

  def channel
    @channel ||= @inbox.channel
  end

  def phone_number
    params[:from]
  end

  def formatted_phone_number
    TelephoneNumber.parse(phone_number).international_number
  end

  def set_contact
    contact_inbox = ::ContactInboxWithContactBuilder.new(
      source_id: params[:from],
      inbox: @inbox,
      contact_attributes: contact_attributes
    ).perform

    @contact_inbox = contact_inbox
    @contact = contact_inbox.contact
  end

  def conversation_params
    {
      account_id: @inbox.account_id,
      inbox_id: @inbox.id,
      contact_id: @contact.id,
      contact_inbox_id: @contact_inbox.id
    }
  end

  def set_conversation
    # if lock to single conversation is disabled, we will create a new conversation if previous conversation is resolved
    @conversation = if @inbox.lock_to_single_conversation
                      @contact_inbox.conversations.last
                    else
                      @contact_inbox.conversations.where
                         
// ... truncated ...

Line::IncomingMessageService (app/services/line/incoming_message_service.rb:4)

class Line::IncomingMessageService
  include ::FileTypeHelper
  pattr_initialize [:inbox!, :params!]
  LINE_STICKER_IMAGE_URL = 'https://stickershop.line-scdn.net/stickershop/v1/sticker/%s/android/sticker.png'.freeze

  def perform
    # probably test events
    return if params[:events].blank?

    parse_events
  end

  private

  def parse_events
    params[:events].each do |event|
      next unless event_type_message?(event)

      get_line_contact_info(event)
      next if @line_contact_info['userId'].blank?

      set_contact
      set_conversation

      next unless message_created? event

      attach_files event['message']
      @message.save!
    end
  end

  def message_created?(event)
    @message = @conversation.messages.build(
      content: message_content(event),
      account_id: @inbox.account_id,
      content_type: message_content_type(event),
      inbox_id: @inbox.id,
      message_type: :incoming,
      sender: @contact,
      source_id: event['message']['id'].to_s
    )
    @message
  end

  def message_content(event)
    message_type = event.dig('message', 'type')
    case message_type
    when 'text'
      event.dig('message', 'text')
    when 'sticker'
      sticker_id = event.dig('message', 'stickerId')
      sticker_image_url(sticker_id)
    end
  end

  # Currently, Chatwoot doesn't support stickers. As a temporary solution,
  # we're displaying stickers as images using the sticker ID in markdown format.
  # This is subject to change in the future
// ... truncated ...

Twilio::IncomingMessageService (app/services/twilio/incoming_message_service.rb:1)

class Twilio::IncomingMessageService
  include ::FileTypeHelper
  include ::Twilio::WhatsappIdentifierHelper

  pattr_initialize [:params!]

  def perform
    return if twilio_channel.blank?

    set_contact
    set_conversation
    @message = @conversation.messages.build(
      content: message_body,
      account_id: @inbox.account_id,
      inbox_id: @inbox.id,
      message_type: :incoming,
      sender: @contact,
      source_id: params[:SmsSid]
    )
    attach_files
    attach_location if location_message?
    @message.save!
  end

  private

  def twilio_channel
    @twilio_channel ||= ::Channel::TwilioSms.find_by(messaging_service_sid: params[:MessagingServiceSid]) if params[:MessagingServiceSid].present?
    if params[:AccountSid].present? && params[:To].present?
      @twilio_channel ||= ::Channel::TwilioSms.find_by(account_sid: params[:AccountSid],
                                                       phone_number: params[:To])
    end
    log_channel_not_found if @twilio_channel.blank?
    @twilio_channel
  end

  def log_channel_not_found
    Rails.logger.warn(
      '[TWILIO] Incoming message channel lookup failed ' \
      "account_sid=#{params[:AccountSid]} " \
      "to=#{params[:To]} " \
      "messaging_service_sid=#{params[:MessagingServiceSid]} " \
      "sms_sid=#{params[:SmsSid]}"
    )
  end

  def inbox
    @inbox ||= twilio_channel.inbox
  end

  def account
    @account ||= inbox.account
  end

  # Twilio WhatsApp phone payloads arrive as `whatsap
// ... truncated ...

perform (app/services/sms/incoming_message_service.rb:6)

  def perform
    set_contact
    set_conversation
    @message = @conversation.messages.create!(
      content: params[:text],
      account_id: @inbox.account_id,
      inbox_id: @inbox.id,
      message_type: :incoming,
      sender: @contact,
      source_id: params[:id]
    )
    attach_files
    @message.save!
  end

account (app/services/sms/incoming_message_service.rb:23)

  def account
    @account ||= @inbox.account
  end

channel (app/services/sms/incoming_message_service.rb:27)

  def channel
    @channel ||= @inbox.channel
  end

phone_number (app/services/sms/incoming_message_service.rb:31)

  def phone_number
    params[:from]
  end

formatted_phone_number (app/services/sms/incoming_message_service.rb:35)

  def formatted_phone_number
    TelephoneNumber.parse(phone_number).international_number
  end

set_contact (app/services/sms/incoming_message_service.rb:39)

  def set_contact
    contact_inbox = ::ContactInboxWithContactBuilder.new(
      source_id: params[:from],
      inbox: @inbox,
      contact_attributes: contact_attributes
    ).perform

    @contact_inbox = contact_inbox
    @contact = contact_inbox.contact
  end

conversation_params (app/services/sms/incoming_message_service.rb:50)

  def conversation_params
    {
      account_id: @inbox.account_id,
      inbox_id: @inbox.id,
      contact_id: @contact.id,
      contact_inbox_id: @contact_inbox.id
    }
  end

set_conversation (app/services/sms/incoming_message_service.rb:59)

  def set_conversation
    # if lock to single conversation is disabled, we will create a new conversation if previous conversation is resolved
    @conversation = if @inbox.lock_to_single_conversation
                      @contact_inbox.conversations.last
                    else
                      @contact_inbox.conversations.where
                                    .not(status: :resolved).last
                    end
    return if @conversation

    @conversation = ::Conversation.create!(conversation_params)
  end

contact_attributes (app/services/sms/incoming_message_service.rb:72)

  def contact_attributes
    {
      name: formatted_phone_number,
      phone_number: phone_number
    }
  end

attach_files (app/services/sms/incoming_message_service.rb:79)

  def attach_files
    return if params[:media].blank?

    params[:media].each do |media_url|
      # we don't need to process this files since chatwoot doesn't support it
      next if media_url.end_with?('.smil', '.xml')

      attachment_file = Down.download(
        media_url,
        http_basic_authentication: [channel.provider_config['api_key'], channel.provider_config['api_secret']]
      )

      @message.attachments.new(
        account_id: @message.account_id,
        file_type: file_type(attachment_file.content_type),
        file: {
          io: attachment_file,
          filename: attachment_file.original_filename,
          content_type: attachment_file.content_type
        }
      )
    end
  end

process_event_params (app/jobs/webhooks/sms_events_job.rb:17)

  def process_event_params(channel, params)
    if delivery_event?(params)
      Sms::DeliveryStatusService.new(channel: channel, params: params[:message].with_indifferent_access).perform
    else
      Sms::IncomingMessageService.new(inbox: channel.inbox, params: params[:message].with_indifferent_access).perform
    end
  end

perform (app/services/line/incoming_message_service.rb:9)

  def perform
    # probably test events
    return if params[:events].blank?

    parse_events
  end

parse_events (app/services/line/incoming_message_service.rb:18)

  def parse_events
    params[:events].each do |event|
      next unless event_type_message?(event)

      get_line_contact_info(event)
      next if @line_contact_info['userId'].blank?

      set_contact
      set_conversation

      next unless message_created? event

      attach_files event['message']
      @message.save!
    end
  end

message_created? (app/services/line/incoming_message_service.rb:35)

  def message_created?(event)
    @message = @conversation.messages.build(
      content: message_content(event),
      account_id: @inbox.account_id,
      content_type: message_content_type(event),
      inbox_id: @inbox.id,
      message_type: :incoming,
      sender: @contact,
      source_id: event['message']['id'].to_s
    )
    @message
  end

message_content (app/services/line/incoming_message_service.rb:48)

  def message_content(event)
    message_type = event.dig('message', 'type')
    case message_type
    when 'text'
      event.dig('message', 'text')
    when 'sticker'
      sticker_id = event.dig('message', 'stickerId')
      sticker_image_url(sticker_id)
    end
  end

sticker_image_url (app/services/line/incoming_message_service.rb:65)

  def sticker_image_url(sticker_id)
    "![sticker-#{sticker_id}](#{LINE_STICKER_IMAGE_URL % sticker_id})"
  end

message_content_type (app/services/line/incoming_message_service.rb:69)

  def message_content_type(event)
    return 'sticker' if event['message']['type'] == 'sticker'

    'text'
  end

attach_files (app/services/line/incoming_message_service.rb:75)

  def attach_files(message)
    return unless message_type_non_text?(message['type'])

    response = inbox.channel.client.get_message_content(message['id'])

    extension = get_file_extension(response)
    file_name = message['fileName'] || "media-#{message['id']}.#{extension}"
    temp_file = Tempfile.new(file_name)
    temp_file.binmode
    temp_file << response.body
    temp_file.rewind

    @message.attachments.new(
      account_id: @message.account_id,
      file_type: file_content_type(response),
      file: {
        io: temp_file,
        filename: file_name,
        content_type: response.content_type
      }
    )
  end

get_file_extension (app/services/line/incoming_message_service.rb:98)

  def get_file_extension(response)
    if response.content_type&.include?('/')
      response.content_type.split('/')[1]
    else
      'bin'
    end
  end

event_type_message? (app/services/line/incoming_message_service.rb:106)

  def event_type_message?(event)
    event['type'] == 'message' || event['type'] == 'sticker'
  end

message_type_non_text? (app/services/line/incoming_message_service.rb:110)

  def message_type_non_text?(type)
    [
      Line::Bot::Event::MessageType::Video,
      Line::Bot::Event::MessageType::Audio,
      Line::Bot::Event::MessageType::Image,
      Line::Bot::Event::MessageType::File
    ].include?(type)
  end

account (app/services/line/incoming_message_service.rb:119)

  def account
    @account ||= inbox.account
  end

get_line_contact_info (app/services/line/incoming_message_service.rb:123)

  def get_line_contact_info(event)
    @line_contact_info = JSON.parse(inbox.channel.client.get_profile(event['source']['userId']).body)
  end

set_contact (app/services/line/incoming_message_service.rb:127)

  def set_contact
    contact_inbox = ::ContactInboxWithContactBuilder.new(
      source_id: @line_contact_info['userId'],
      inbox: inbox,
      contact_attributes: contact_attributes
    ).perform

    @contact_inbox = contact_inbox
    @contact = contact_inbox.contact
  end

conversation_params (app/services/line/incoming_message_service.rb:138)

  def conversation_params
    {
      account_id: @inbox.account_id,
      inbox_id: @inbox.id,
      contact_id: @contact.id,
      contact_inbox_id: @contact_inbox.id
    }
  end

set_conversation (app/services/line/incoming_message_service.rb:147)

  def set_conversation
    # if lock to single conversation is disabled, we will create a new conversation if previous conversation is resolved
    @conversation = if @inbox.lock_to_single_conversation
                      @contact_inbox.conversations.last
                    else
                      @contact_inbox.conversations.where.not(status: :resolved).last
                    end
    return if @conversation

    @conversation = ::Conversation.create!(conversation_params)
  end

contact_attributes (app/services/line/incoming_message_service.rb:159)

  def contact_attributes
    {
      name: @line_contact_info['displayName'],
      avatar_url: @line_contact_info['pictureUrl'],
      additional_attributes: additional_attributes
    }
  end