清理: - 删除 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 相关修改
501 lines
15 KiB
Markdown
501 lines
15 KiB
Markdown
## 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
|
|
|
|
### Related Symbols
|
|
|
|
- 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)
|
|
|
|
```ruby
|
|
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)
|
|
|
|
```ruby
|
|
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)
|
|
|
|
```javascript
|
|
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)
|
|
|
|
```ruby
|
|
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)
|
|
|
|
```ruby
|
|
def account(event)
|
|
event.data[:account]
|
|
end
|
|
```
|
|
|
|
#### users (app/listeners/installation_webhook_listener.rb:16)
|
|
|
|
```ruby
|
|
def users(event)
|
|
account(event).administrators.map(&:webhook_data)
|
|
end
|
|
```
|
|
|
|
#### deliver_webhook_payloads (app/listeners/installation_webhook_listener.rb:20)
|
|
|
|
```ruby
|
|
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)
|
|
|
|
```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
|
|
```
|
|
|
|
#### notification_created (app/listeners/action_cable_listener.rb:4)
|
|
|
|
```ruby
|
|
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)
|
|
|
|
```ruby
|
|
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)
|
|
|
|
```ruby
|
|
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)
|
|
|
|
```ruby
|
|
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)
|
|
|
|
```ruby
|
|
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)
|
|
|
|
```ruby
|
|
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)
|
|
|
|
```ruby
|
|
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)
|
|
|
|
```ruby
|
|
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)
|
|
|
|
```ruby
|
|
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)
|
|
|
|
```ruby
|
|
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)
|
|
|
|
```ruby
|
|
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)
|
|
|
|
```ruby
|
|
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)
|
|
|
|
```ruby
|
|
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)
|
|
|
|
```ruby
|
|
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)
|
|
|
|
```ruby
|
|
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)
|
|
|
|
```ruby
|
|
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)
|
|
|
|
```ruby
|
|
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)
|
|
|
|
```ruby
|
|
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)
|
|
|
|
```ruby
|
|
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)
|
|
|
|
```ruby
|
|
def listeners
|
|
[ActionCableListener.instance, AgentBotListener.instance]
|
|
end
|
|
```
|
|
|
|
#### <anonymous> (app/javascript/shared/helpers/BaseActionCableConnector.js:7)
|
|
|
|
```javascript
|
|
static isDisconnected = false;
|
|
```
|
|
|
|
#### constructor (app/javascript/shared/helpers/BaseActionCableConnector.js:9)
|
|
|
|
```javascript
|
|
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();
|
|
}
|
|
``` |