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

15 KiB

Code Context

Query: notification, notification subscription, webhook, integration hook, installation webhook, action cable listener, push notification, email notification

Entry Points

  • InstallationWebhookListener (class) - app/listeners/installation_webhook_listener.rb:1
  • ActionCableListener (class) - app/listeners/action_cable_listener.rb:1
  • BaseActionCableConnector (class) - app/javascript/shared/helpers/BaseActionCableConnector.js:6
  • app/listeners/base_listener.rb: BaseListener:1
  • app/listeners/installation_webhook_listener.rb: account_created:2, account:12, users:16, deliver_webhook_payloads:20
  • app/dispatchers/async_dispatcher.rb: listeners:11
  • app/listeners/action_cable_listener.rb: notification_created:4, notification_updated:10, notification_deleted:16, account_cache_invalidated:32

Code

InstallationWebhookListener (app/listeners/installation_webhook_listener.rb:1)

class InstallationWebhookListener < BaseListener
  def account_created(event)
    payload = account(event).webhook_data.merge(
      event: __method__.to_s,
      users: users(event)
    )
    deliver_webhook_payloads(payload)
  end

  private

  def account(event)
    event.data[:account]
  end

  def users(event)
    account(event).administrators.map(&:webhook_data)
  end

  def deliver_webhook_payloads(payload)
    # Deliver the installation event
    webhook_url = InstallationConfig.find_by(name: 'INSTALLATION_EVENTS_WEBHOOK_URL')&.value
    WebhookJob.perform_later(webhook_url, payload) if webhook_url
  end
end

ActionCableListener (app/listeners/action_cable_listener.rb:1)

class ActionCableListener < BaseListener
  include Events::Types

  def notification_created(event)
    notification, account, unread_count, count = extract_notification_and_account(event)
    tokens = [event.data[:notification].user.pubsub_token]
    broadcast(account, tokens, NOTIFICATION_CREATED, { notification: notification.push_event_data, unread_count: unread_count, count: count })
  end

  def notification_updated(event)
    notification, account, unread_count, count = extract_notification_and_account(event)
    tokens = [event.data[:notification].user.pubsub_token]
    broadcast(account, tokens, NOTIFICATION_UPDATED, { notification: notification.push_event_data, unread_count: unread_count, count: count })
  end

  def notification_deleted(event)
    notification_data = event.data[:notification_data]

    user = User.find_by(id: notification_data[:user_id])
    account = Account.find_by(id: notification_data[:account_id])
    return if user.blank? || account.blank?

    notification_finder = NotificationFinder.new(user, account)
    tokens = [user.pubsub_token]
    broadcast(account, tokens, NOTIFICATION_DELETED, {
                notification: { id: notification_data[:id] },
                unread_count: notification_finder.unread_count,
                count: notification_finder.count
              })
  end

  def account_cache_invalidated(event)
    account = event.data[:account]
    tokens = user_tokens(account, account.agents)

    broadcast(account, tokens, ACCOU
// ... truncated ...

BaseActionCableConnector (app/javascript/shared/helpers/BaseActionCableConnector.js:6)

class BaseActionCableConnector {
  static isDisconnected = false;

  constructor(
    app,
    pubsubToken,
    websocketHost = '',
    presenceInterval = PRESENCE_INTERVAL
  ) {
    const websocketURL = websocketHost ? `${websocketHost}/cable` : undefined;

    this.consumer = createConsumer(websocketURL);
    this.subscription = this.consumer.subscriptions.create(
      {
        channel: 'RoomChannel',
        pubsub_token: pubsubToken,
        account_id: app.$store.getters.getCurrentAccountId,
        user_id: app.$store.getters.getCurrentUserID,
      },
      {
        updatePresence() {
          this.perform('update_presence');
        },
        received: this.onReceived,
        disconnected: () => {
          BaseActionCableConnector.isDisconnected = true;
          this.onDisconnected();
          this.initReconnectTimer();
        },
      }
    );
    this.app = app;
    this.events = {};
    this.reconnectTimer = null;
    this.isAValidEvent = () => true;
    this.triggerPresenceInterval = () => {
      setTimeout(() => {
        this.subscription.updatePresence();
        this.triggerPresenceInterval();
      }, presenceInterval);
    };
    this.triggerPresenceInterval();
  }

  checkConnection() {
    const isConnectionActive = this.consumer.connection.isOpen();
    const isReconnected =
      BaseActionCableConnector.isDisconnected && isConnectionActive;
    if (isReconnected) {
      this.clearReconnectTimer();
      this.onReconnect();
      BaseActionCa
// ... truncated ...

account_created (app/listeners/installation_webhook_listener.rb:2)

  def account_created(event)
    payload = account(event).webhook_data.merge(
      event: __method__.to_s,
      users: users(event)
    )
    deliver_webhook_payloads(payload)
  end

account (app/listeners/installation_webhook_listener.rb:12)

  def account(event)
    event.data[:account]
  end

users (app/listeners/installation_webhook_listener.rb:16)

  def users(event)
    account(event).administrators.map(&:webhook_data)
  end

deliver_webhook_payloads (app/listeners/installation_webhook_listener.rb:20)

  def deliver_webhook_payloads(payload)
    # Deliver the installation event
    webhook_url = InstallationConfig.find_by(name: 'INSTALLATION_EVENTS_WEBHOOK_URL')&.value
    WebhookJob.perform_later(webhook_url, payload) if webhook_url
  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

notification_created (app/listeners/action_cable_listener.rb:4)

  def notification_created(event)
    notification, account, unread_count, count = extract_notification_and_account(event)
    tokens = [event.data[:notification].user.pubsub_token]
    broadcast(account, tokens, NOTIFICATION_CREATED, { notification: notification.push_event_data, unread_count: unread_count, count: count })
  end

notification_updated (app/listeners/action_cable_listener.rb:10)

  def notification_updated(event)
    notification, account, unread_count, count = extract_notification_and_account(event)
    tokens = [event.data[:notification].user.pubsub_token]
    broadcast(account, tokens, NOTIFICATION_UPDATED, { notification: notification.push_event_data, unread_count: unread_count, count: count })
  end

notification_deleted (app/listeners/action_cable_listener.rb:16)

  def notification_deleted(event)
    notification_data = event.data[:notification_data]

    user = User.find_by(id: notification_data[:user_id])
    account = Account.find_by(id: notification_data[:account_id])
    return if user.blank? || account.blank?

    notification_finder = NotificationFinder.new(user, account)
    tokens = [user.pubsub_token]
    broadcast(account, tokens, NOTIFICATION_DELETED, {
                notification: { id: notification_data[:id] },
                unread_count: notification_finder.unread_count,
                count: notification_finder.count
              })
  end

account_cache_invalidated (app/listeners/action_cable_listener.rb:32)

  def account_cache_invalidated(event)
    account = event.data[:account]
    tokens = user_tokens(account, account.agents)

    broadcast(account, tokens, ACCOUNT_CACHE_INVALIDATED, {
                cache_keys: event.data[:cache_keys]
              })
  end

message_created (app/listeners/action_cable_listener.rb:41)

  def message_created(event)
    message, account = extract_message_and_account(event)
    conversation = message.conversation
    tokens = user_tokens(account, conversation.inbox.members) + contact_tokens(conversation.contact_inbox, message)

    broadcast(account, tokens, MESSAGE_CREATED, message.push_event_data)
  end

message_updated (app/listeners/action_cable_listener.rb:49)

  def message_updated(event)
    message, account = extract_message_and_account(event)
    conversation = message.conversation
    tokens = user_tokens(account, conversation.inbox.members) + contact_tokens(conversation.contact_inbox, message)

    broadcast(account, tokens, MESSAGE_UPDATED, message.push_event_data.merge(previous_changes: event.data[:previous_changes]))
  end

first_reply_created (app/listeners/action_cable_listener.rb:57)

  def first_reply_created(event)
    message, account = extract_message_and_account(event)
    conversation = message.conversation
    tokens = user_tokens(account, conversation.inbox.members)

    broadcast(account, tokens, FIRST_REPLY_CREATED, message.push_event_data)
  end

conversation_created (app/listeners/action_cable_listener.rb:65)

  def conversation_created(event)
    conversation, account = extract_conversation_and_account(event)
    tokens = user_tokens(account, conversation.inbox.members) + contact_inbox_tokens(conversation.contact_inbox)

    broadcast(account, tokens, CONVERSATION_CREATED, conversation.push_event_data)
  end

conversation_read (app/listeners/action_cable_listener.rb:72)

  def conversation_read(event)
    conversation, account = extract_conversation_and_account(event)
    tokens = user_tokens(account, conversation.inbox.members)

    broadcast(account, tokens, CONVERSATION_READ, conversation.push_event_data)
  end

conversation_status_changed (app/listeners/action_cable_listener.rb:79)

  def conversation_status_changed(event)
    conversation, account = extract_conversation_and_account(event)
    tokens = user_tokens(account, conversation.inbox.members) + contact_inbox_tokens(conversation.contact_inbox)

    broadcast(account, tokens, CONVERSATION_STATUS_CHANGED, conversation.push_event_data)
  end

conversation_updated (app/listeners/action_cable_listener.rb:86)

  def conversation_updated(event)
    conversation, account = extract_conversation_and_account(event)
    tokens = user_tokens(account, conversation.inbox.members) + contact_inbox_tokens(conversation.contact_inbox)

    broadcast(account, tokens, CONVERSATION_UPDATED, conversation.push_event_data)
  end

conversation_unread_count_changed (app/listeners/action_cable_listener.rb:93)

  def conversation_unread_count_changed(event)
    account, inbox_members = ::Conversations::UnreadCounts::BroadcastScope.new(event).perform
    return if account.blank? || !account.feature_enabled?('conversation_unread_counts')

    tokens = user_tokens(account, inbox_members)

    broadcast(account, tokens, CONVERSATION_UNREAD_COUNT_CHANGED, {})
  end

conversation_typing_on (app/listeners/action_cable_listener.rb:102)

  def conversation_typing_on(event)
    conversation = event.data[:conversation]
    account = conversation.account
    user = event.data[:user]
    tokens = typing_event_listener_tokens(account, conversation, user)

    broadcast(
      account,
      tokens,
      CONVERSATION_TYPING_ON,
      conversation: conversation.push_event_data,
      user: user.push_event_data,
      is_private: event.data[:is_private] || false
    )
  end

conversation_typing_off (app/listeners/action_cable_listener.rb:118)

  def conversation_typing_off(event)
    conversation = event.data[:conversation]
    account = conversation.account
    user = event.data[:user]
    tokens = typing_event_listener_tokens(account, conversation, user)

    broadcast(
      account,
      tokens,
      CONVERSATION_TYPING_OFF,
      conversation: conversation.push_event_data,
      user: user.push_event_data,
      is_private: event.data[:is_private] || false
    )
  end

assignee_changed (app/listeners/action_cable_listener.rb:134)

  def assignee_changed(event)
    conversation, account = extract_conversation_and_account(event)
    tokens = user_tokens(account, conversation.inbox.members)

    broadcast(account, tokens, ASSIGNEE_CHANGED, conversation.push_event_data)
  end

team_changed (app/listeners/action_cable_listener.rb:141)

  def team_changed(event)
    conversation, account = extract_conversation_and_account(event)
    tokens = user_tokens(account, conversation.inbox.members)

    broadcast(account, tokens, TEAM_CHANGED, conversation.push_event_data)
  end

conversation_contact_changed (app/listeners/action_cable_listener.rb:148)

  def conversation_contact_changed(event)
    conversation, account = extract_conversation_and_account(event)
    tokens = user_tokens(account, conversation.inbox.members)

    broadcast(account, tokens, CONVERSATION_CONTACT_CHANGED, conversation.push_event_data)
  end

contact_created (app/listeners/action_cable_listener.rb:155)

  def contact_created(event)
    contact, account = extract_contact_and_account(event)
    broadcast(account, [account_token(account)], CONTACT_CREATED, contact.push_event_data)
  end

contact_updated (app/listeners/action_cable_listener.rb:160)

  def contact_updated(event)
    contact, account = extract_contact_and_account(event)
    broadcast(account, [account_token(account)], CONTACT_UPDATED, contact.push_event_data)
  end

listeners (app/dispatchers/sync_dispatcher.rb:7)

  def listeners
    [ActionCableListener.instance, AgentBotListener.instance]
  end

(app/javascript/shared/helpers/BaseActionCableConnector.js:7)

  static isDisconnected = false;

constructor (app/javascript/shared/helpers/BaseActionCableConnector.js:9)

  constructor(
    app,
    pubsubToken,
    websocketHost = '',
    presenceInterval = PRESENCE_INTERVAL
  ) {
    const websocketURL = websocketHost ? `${websocketHost}/cable` : undefined;

    this.consumer = createConsumer(websocketURL);
    this.subscription = this.consumer.subscriptions.create(
      {
        channel: 'RoomChannel',
        pubsub_token: pubsubToken,
        account_id: app.$store.getters.getCurrentAccountId,
        user_id: app.$store.getters.getCurrentUserID,
      },
      {
        updatePresence() {
          this.perform('update_presence');
        },
        received: this.onReceived,
        disconnected: () => {
          BaseActionCableConnector.isDisconnected = true;
          this.onDisconnected();
          this.initReconnectTimer();
        },
      }
    );
    this.app = app;
    this.events = {};
    this.reconnectTimer = null;
    this.isAValidEvent = () => true;
    this.triggerPresenceInterval = () => {
      setTimeout(() => {
        this.subscription.updatePresence();
        this.triggerPresenceInterval();
      }, presenceInterval);
    };
    this.triggerPresenceInterval();
  }