清理: - 删除 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 相关修改
1694 lines
63 KiB
Markdown
1694 lines
63 KiB
Markdown
# P2D — GoChat 渠道抽象层设计
|
||
|
||
> 版本:v1.1 | 更新日期:2026-07-09
|
||
> 参照:Chatwoot v3.x `Channelable` concern + 多态 Channel 模型
|
||
> 需求基线:M2-Inbox与渠道管理 + M8-通知与Webhook
|
||
> 状态:设计文档,渠道抽象已落地。当前 9 个 Provider 实现(WebWidget/Telegram/Email/Facebook/Instagram/WhatsApp/Twilio/LINE/TikTok)。
|
||
|
||
---
|
||
|
||
## 目录
|
||
|
||
1. [设计哲学与目标](#1-设计哲学与目标)
|
||
2. [与Chatwoot Channelable对比分析](#2-与chatwoot-channelable对比分析)
|
||
3. [ChannelProvider核心接口定义](#3-channelprovider核心接口定义)
|
||
4. [ChannelRegistry注册机制](#4-channelregistry注册机制)
|
||
5. [各渠道Provider实现设计](#5-各渠道provider实现设计)
|
||
6. [Webhook回调统一处理](#6-webhook回调统一处理)
|
||
7. [渠道配置模型](#7-渠道配置模型)
|
||
8. [消息编解码架构](#8-消息编解码架构)
|
||
9. [OAuth刷新与Reauthorization机制](#9-oauth刷新与reauthorization机制)
|
||
10. [Inbox聚合模型](#10-inbox聚合模型)
|
||
11. [第一阶段渠道实现优先级](#11-第一阶段渠道实现优先级)
|
||
|
||
---
|
||
|
||
## 1. 设计哲学与目标
|
||
|
||
### 1.1 核心原则
|
||
|
||
**「渠道即插件,接口即契约」** — GoChat 的渠道抽象层是整个系统扩展性的基石。
|
||
|
||
Chatwoot 的 `Channelable` concern 是一个极简的 Rails concern(仅 13 行代码),只提供 `belongs_to :account` + `has_one :inbox` + `after_update :create_audit_log_entry`。真正的渠道差异化逻辑散布在各自的 Channel 模型、Service 层和 Webhook Controller 中。这种"隐式接口"模式在 Ruby 中可行(duck typing),但在 Go 的强类型体系中无法直接移植。
|
||
|
||
GoChat 需要一种**显式接口契约**的方式,让每个渠道 Provider 通过统一的接口方法集接入系统,同时保留各自渠道的独立配置和业务逻辑。
|
||
|
||
### 1.2 设计目标
|
||
|
||
| 目标 | 说明 |
|
||
|------|------|
|
||
| **类型安全的渠道扩展** | 通过 Go interface 定义显式契约,新增渠道只需实现接口+注册,零修改核心代码 |
|
||
| **统一的消息收发管道** | 所有渠道的 incoming/sending 都经过统一的编解码层,转换为内部 Message 模型 |
|
||
| **Webhook 统一入口** | 所有外部渠道回调通过单一 `/webhooks/{channel_type}/{identifier}` 路径进入 |
|
||
| **配置隔离** | 每个渠道有独立的 GORM 模型存储配置,通过 JSON 字段支持灵活扩展 |
|
||
| **OAuth 可插拔** | 需 OAuth 的渠道通过 `OAuthProvider` 子接口接入,统一刷新/重授权流程 |
|
||
| **运行时动态注册** | ChannelRegistry 在 init() 时完成注册,支持未来通过配置文件动态启用/禁用渠道 |
|
||
|
||
---
|
||
|
||
## 2. 与Chatwoot Channelable对比分析
|
||
|
||
### 2.1 Chatwoot 原实现
|
||
|
||
```ruby
|
||
# Chatwoot: app/models/concerns/channelable.rb (13行)
|
||
module Channelable
|
||
extend ActiveSupport::Concern
|
||
included do
|
||
validates :account_id, presence: true
|
||
belongs_to :account
|
||
has_one :inbox, as: :channel, dependent: :destroy_async, touch: true
|
||
after_update :create_audit_log_entry
|
||
end
|
||
def create_audit_log_entry; end
|
||
end
|
||
```
|
||
|
||
Chatwoot 的渠道差异逻辑实现方式:
|
||
|
||
| 差异点 | Chatwoot 实现方式 | 问题 |
|
||
|--------|-------------------|------|
|
||
| 收消息 | 各渠道独立 Webhook Controller(`Telegram::WebhooksController`等) | 无统一入站管道 |
|
||
| 发消息 | 各渠道独立 Service(`Telegram::SendOnTelegramService`等) | 无统一出站管道 |
|
||
| 消息格式 | 各 Service 直接操作 Telegram API / FB API 等 | 编解码逻辑分散 |
|
||
| 创建验证 | 各 Channel Model 的 `before_validation` / `before_save` | 无法统一验证框架 |
|
||
| OAuth | `Reauthorizable` concern 只覆盖 4 个渠道 | 缺乏统一 OAuth 接口 |
|
||
| 配置存储 | 各 Channel 独立表 + `EDITABLE_ATTRS` 常量 | 无统一配置 schema |
|
||
|
||
### 2.2 GoChat 改进设计
|
||
|
||
GoChat 采用**策略模式 + 注册表模式**替代 Chatwoot 的隐式 concern + 独立 Service 模式:
|
||
|
||
| Chatwoot 概念 | GoChat 对应 | 改进点 |
|
||
|---------------|-------------|--------|
|
||
| `Channelable` concern | `ChannelProvider` interface | 从隐式 duck typing → 显式 Go interface 契约 |
|
||
| 各渠道独立 Webhook Controller | 统一 `WebhookHandler` + 渠道路由 | 单一入口,按 channel_type 分发 |
|
||
| 各渠道独立 Send Service | `ChannelProvider.SendMessage()` 方法 | 统一出站管道 |
|
||
| `EDITABLE_ATTRS` 常量 | `ConfigSchema()` 返回 JSON Schema | 可动态验证配置字段 |
|
||
| `Reauthorizable` concern | `OAuthProvider` 子接口 | 统一 OAuth 刷新/重授权流程 |
|
||
| 多态 `channel_type + channel_id` | `Inbox.ChannelConfig` JSON + Provider 查找 | 更灵活的配置存储 |
|
||
|
||
---
|
||
|
||
## 3. ChannelProvider核心接口定义
|
||
|
||
### 3.1 接口层次结构
|
||
|
||
```
|
||
ChannelProvider (基础接口 — 所有渠道必须实现)
|
||
├── OAuthProvider (子接口 — 需OAuth的渠道额外实现)
|
||
├── PollingProvider (子接口 — 需主动拉取的渠道额外实现)
|
||
└── PushProvider (子接口 — 支持推送通知的渠道额外实现)
|
||
```
|
||
|
||
### 3.2 ChannelProvider — 基础接口
|
||
|
||
```go
|
||
// pkg/channel/provider.go
|
||
|
||
package channel
|
||
|
||
import (
|
||
"context"
|
||
"time"
|
||
|
||
"gochat/internal/model"
|
||
)
|
||
|
||
// ChannelType 渠道类型标识符
|
||
type ChannelType string
|
||
|
||
const (
|
||
ChannelWebWidget ChannelType = "web_widget"
|
||
ChannelTelegram ChannelType = "telegram"
|
||
ChannelFacebook ChannelType = "facebook"
|
||
ChannelInstagram ChannelType = "instagram"
|
||
ChannelWhatsApp ChannelType = "whatsapp"
|
||
ChannelEmail ChannelType = "email"
|
||
ChannelTwilioSMS ChannelType = "twilio_sms"
|
||
ChannelTwilioWA ChannelType = "twilio_whatsapp"
|
||
ChannelSMS ChannelType = "sms"
|
||
ChannelLine ChannelType = "line"
|
||
ChannelAPI ChannelType = "api"
|
||
)
|
||
|
||
// ChannelProvider 是所有渠道 Provider 必须实现的核心接口
|
||
// 对应 Chatwoot 的 Channelable concern + 各渠道 Service 层
|
||
//
|
||
// 设计原则:
|
||
// - 接口方法粒度按"消息生命周期"划分(创建→发送→接收→回调→销毁)
|
||
// - 每个 Provider 是无状态的,渠道状态通过 Inbox.ChannelConfig + GORM 模型持久化
|
||
// - 上下文传递使用 context.Context,支持超时/取消/追踪
|
||
type ChannelProvider interface {
|
||
// === 身份与元信息 ===
|
||
|
||
// Type 返回渠道类型标识符
|
||
Type() ChannelType
|
||
|
||
// Name 返回渠道显示名称(如 "Telegram", "Web Widget")
|
||
Name() string
|
||
|
||
// Description 返回渠道简短描述
|
||
Description() string
|
||
|
||
// === 配置与验证 ===
|
||
|
||
// ConfigSchema 返回渠道配置的 JSON Schema 定义
|
||
// 用于前端动态渲染配置表单、后端验证配置字段
|
||
// 对应 Chatwoot 各渠道的 EDITABLE_ATTRS
|
||
ConfigSchema() *ConfigSchemaDefinition
|
||
|
||
// ValidateConfig 验证渠道配置的有效性
|
||
// 创建/更新 Inbox 时调用,替代 Chatwoot 各 Channel Model 的 before_validation
|
||
ValidateConfig(ctx context.Context, config ChannelConfig) error
|
||
|
||
// DefaultConfig 返回渠道的默认配置(含默认值)
|
||
DefaultConfig() ChannelConfig
|
||
|
||
// === 创建与销毁 ===
|
||
|
||
// OnCreate 渠道创建后的回调
|
||
// 对应 Chatwoot 的 before_save :setup_telegram_webhook 等
|
||
// 返回的可能包括需要持久化的额外配置(如 webhook URL、token 等)
|
||
OnCreate(ctx context.Context, inbox *model.Inbox, config ChannelConfig) (ChannelConfig, error)
|
||
|
||
// OnDestroy 渠道销毁前的回调
|
||
// 用于清理外部资源(如删除 Telegram Webhook、取消 FB Page订阅等)
|
||
OnDestroy(ctx context.Context, inbox *model.Inbox, config ChannelConfig) error
|
||
|
||
// === 消息接收(Inbound)===
|
||
|
||
// ProcessIncoming 处理外部渠道推送的原始消息数据
|
||
// 从 Webhook 回调或 Polling 拉取的原始数据 → 转换为 IncomingMessage
|
||
// 对应 Chatwoot 各渠道的 WebhooksController + MessageBuilder
|
||
ProcessIncoming(ctx context.Context, inbox *model.Inbox, rawPayload []byte) (*IncomingMessage, error)
|
||
|
||
// ValidateWebhookRequest 验证 Webhook 回调请求的真实性
|
||
// 每个 Provider 实现自己的签名验证逻辑(FB签名、Telegram token、WhatsApp HMAC等)
|
||
ValidateWebhookRequest(ctx context.Context, inbox *model.Inbox, request *WebhookRequest) error
|
||
|
||
// === 消息发送(Outbound)===
|
||
|
||
// SendMessage 发送消息到外部渠道
|
||
// 对应 Chatwoot 各渠道的 SendOn*Service
|
||
// 返回外部渠道返回的消息ID(用于关联追踪)
|
||
SendMessage(ctx context.Context, inbox *model.Inbox, message *model.Message, contact *model.Contact) (*SendResult, error)
|
||
|
||
// === 联系人信息 ===
|
||
|
||
// GetContactProfile 从外部渠道获取联系人资料(头像、名称等)
|
||
// 对应 Chatwoot 的 Telegram.get_telegram_profile_image / Facebook.get_user_profile 等
|
||
GetContactProfile(ctx context.Context, inbox *model.Inbox, contactSource string) (*ContactProfile, error)
|
||
|
||
// === 能力声明 ===
|
||
|
||
// Capabilities 返回该渠道支持的功能集合
|
||
// 用于前端判断该 Inbox 可用的功能(如是否支持附件、是否支持打字指示器等)
|
||
Capabilities() ChannelCapabilities
|
||
}
|
||
```
|
||
|
||
### 3.3 OAuthProvider — OAuth渠道子接口
|
||
|
||
```go
|
||
// pkg/channel/oauth_provider.go
|
||
|
||
package channel
|
||
|
||
import (
|
||
"context"
|
||
)
|
||
|
||
// OAuthProvider 需要外部 OAuth 认证的渠道额外实现的子接口
|
||
// 对应 Chatwoot 的 Reauthorizable concern + RefreshOauthTokenService
|
||
//
|
||
// 适用渠道:Facebook, Instagram, WhatsApp(360dialog), TikTok, Google, Microsoft
|
||
type OAuthProvider interface {
|
||
ChannelProvider // 嵌入基础接口
|
||
|
||
// OAuthConfig 返回 OAuth 配置需求定义
|
||
OAuthConfig() *OAuthConfigDefinition
|
||
|
||
// BuildAuthURL 构建 OAuth 授权跳转 URL
|
||
// 对应 Chatwoot 的 omniauth authorize URL
|
||
BuildAuthURL(ctx context.Context, accountID uint64, redirectURL string) (string, error)
|
||
|
||
// ExchangeToken 通过 OAuth code 交换 access_token
|
||
// 对应 Chatwoot 的 omniauth callback 处理
|
||
ExchangeToken(ctx context.Context, code string, redirectURL string) (*OAuthTokenResult, error)
|
||
|
||
// RefreshToken 刷新过期的 access_token
|
||
// 对应 Chatwoot 各渠道的 RefreshOauthTokenService
|
||
RefreshToken(ctx context.Context, inbox *model.Inbox, config ChannelConfig) (*OAuthTokenResult, error)
|
||
|
||
// CheckAuthorizationError 检查 API 调用是否返回了授权错误
|
||
// 对应 Chatwoot Reauthorizable.authorization_error!
|
||
CheckAuthorizationError(ctx context.Context, apiError error) bool
|
||
|
||
// OnReauthorization 需要重新授权时的回调
|
||
// 对应 Chatwoot 的 prompt_reauthorization! → 发邮件 + UI 提示
|
||
OnReauthorization(ctx context.Context, inbox *model.Inbox) error
|
||
}
|
||
```
|
||
|
||
### 3.4 PollingProvider — 主动拉取渠道子接口
|
||
|
||
```go
|
||
// pkg/channel/polling_provider.go
|
||
|
||
package channel
|
||
|
||
import (
|
||
"context"
|
||
)
|
||
|
||
// PollingProvider 需要主动拉取消息的渠道额外实现的子接口
|
||
// 对应 Chatwoot 的 Imap::FetchService / Scheduler 定时拉取
|
||
//
|
||
// 适用渠道:Email (IMAP), Line (polling)
|
||
type PollingProvider interface {
|
||
ChannelProvider // 嵌入基础接口
|
||
|
||
// PollInterval 返回拉取间隔时间
|
||
PollInterval() time.Duration
|
||
|
||
// Poll 执行一次消息拉取
|
||
// 返回拉取到的消息列表,空列表表示无新消息
|
||
Poll(ctx context.Context, inbox *model.Inbox, config ChannelConfig) ([]*IncomingMessage, error)
|
||
|
||
// ShouldPoll 判断是否应该执行拉取(可能基于配置开关)
|
||
ShouldPoll(ctx context.Context, inbox *model.Inbox) bool
|
||
}
|
||
```
|
||
|
||
### 3.5 PushProvider — 推送通知渠道子接口
|
||
|
||
```go
|
||
// pkg/channel/push_provider.go
|
||
|
||
package channel
|
||
|
||
import (
|
||
"context"
|
||
)
|
||
|
||
// PushProvider 支持推送消息到渠道端的渠道子接口
|
||
// 对应 Chatwoot 的 Widget SDK推送 / FB Messenger send API 等
|
||
// Web Widget 的打字指示器、在线状态推送等能力
|
||
type PushProvider interface {
|
||
ChannelProvider
|
||
|
||
// PushTypingOn 推送"正在输入"状态到渠道端
|
||
PushTypingOn(ctx context.Context, inbox *model.Inbox, conversationID uint64) error
|
||
|
||
// PushTypingOff 推送"停止输入"状态到渠道端
|
||
PushTypingOff(ctx context.Context, inbox *model.Inbox, conversationID uint64) error
|
||
|
||
// PushOnlineStatus 推送坐席在线状态到渠道端
|
||
PushOnlineStatus(ctx context.Context, inbox *model.Inbox, agentAvailable bool) error
|
||
}
|
||
```
|
||
|
||
### 3.6 核心数据结构
|
||
|
||
```go
|
||
// pkg/channel/types.go
|
||
|
||
package channel
|
||
|
||
import (
|
||
"time"
|
||
)
|
||
|
||
// ChannelConfig 渠道配置,以 JSON 格式存储在 Inbox.ChannelConfig 字段
|
||
// 每个 Provider 定义自己的配置 schema,这里提供统一包装
|
||
type ChannelConfig map[string]interface{}
|
||
|
||
// ConfigSchemaDefinition 配置 Schema 定义
|
||
// 对应 Chatwoot 各渠道的 EDITABLE_ATTRS,但以 JSON Schema 方式提供
|
||
type ConfigSchemaDefinition struct {
|
||
Type ChannelType
|
||
Fields []ConfigField
|
||
Required []string // 必填字段列表
|
||
Defaults ChannelConfig // 默认值
|
||
Validation []ValidationRule // 自定义验证规则
|
||
}
|
||
|
||
type ConfigField struct {
|
||
Name string
|
||
Type string // "string", "integer", "boolean", "json", "enum", "url"
|
||
Label string // 显示名称
|
||
Description string // 描述文本
|
||
Placeholder string // 前端占位文本
|
||
Required bool
|
||
Secret bool // 是否为敏感字段(token、密码等)
|
||
EnumValues []string // enum 类型可选值
|
||
SubFields []ConfigField // json 类型嵌套字段
|
||
}
|
||
|
||
type ValidationRule struct {
|
||
Field string
|
||
Rule string // "unique", "url", "email", "regex", "custom"
|
||
Pattern string // regex pattern (if rule="regex")
|
||
Message string // 错误消息
|
||
}
|
||
|
||
// IncomingMessage 从外部渠道接收的标准化消息结构
|
||
// 所有渠道的原始数据经 ProcessIncoming() 转换为此结构
|
||
type IncomingMessage struct {
|
||
// 消息内容
|
||
Content string // 文本内容
|
||
ContentType model.MessageContentType // 内容类型 (text/image/file/video/audio/location/etc)
|
||
Private bool // 是否为内部备注
|
||
Attachments []IncomingAttachment // 附件列表
|
||
|
||
// 来源信息
|
||
SourceID string // 外部渠道的消息ID
|
||
SenderSourceID string // 外部渠道的发送者ID
|
||
SenderName string // 发送者名称
|
||
SenderType SenderType // 发送者类型
|
||
|
||
// 对话信息
|
||
ConversationSourceID string // 外部渠道的对话/线程ID
|
||
ContactInboxSourceID string // 联系人渠道标识(如 FB PSID、Telegram chat_id)
|
||
|
||
// 元数据
|
||
InReplyToExternalID string // 回复的外部消息ID
|
||
EchoMessageID uint64 // 如果是回显消息(坐席发送的回执),关联内部消息ID
|
||
Timestamp time.Time // 消息原始时间
|
||
RawPayload []byte // 保留原始数据用于调试/审计
|
||
Extra map[string]interface{} // 渠道特定元数据
|
||
}
|
||
|
||
type SenderType string
|
||
const (
|
||
SenderContact SenderType = "contact"
|
||
SenderAgent SenderType = "agent"
|
||
SenderBot SenderType = "bot"
|
||
)
|
||
|
||
type IncomingAttachment struct {
|
||
URL string
|
||
ContentType string
|
||
FileName string
|
||
FileSize int64
|
||
RemoteID string // 外部文件ID
|
||
ThumbnailURL string // 缩略图URL
|
||
}
|
||
|
||
// SendResult 消息发送结果
|
||
type SendResult struct {
|
||
ExternalMessageID string // 外部渠道返回的消息ID
|
||
Success bool
|
||
Error error
|
||
Timestamp time.Time
|
||
Extra map[string]interface{} // 渠道特定返回数据
|
||
}
|
||
|
||
// ContactProfile 外部渠道联系人资料
|
||
type ContactProfile struct {
|
||
Name string
|
||
AvatarURL string
|
||
Email string
|
||
Phone string
|
||
Bio string
|
||
Extra map[string]interface{}
|
||
}
|
||
|
||
// WebhookRequest Webhook 回调请求包装
|
||
type WebhookRequest struct {
|
||
ChannelType ChannelType
|
||
Identifier string // 渠道标识(如 website_token / bot_token / phone_number)
|
||
Headers map[string]string
|
||
QueryParams map[string]string
|
||
Body []byte
|
||
Method string
|
||
}
|
||
|
||
// OAuthConfigDefinition OAuth 配置定义
|
||
type OAuthConfigDefinition struct {
|
||
ProviderName string // "facebook", "google", "microsoft" 等
|
||
AuthURL string // OAuth 授权 URL
|
||
TokenURL string // Token 交换 URL
|
||
Scopes []string // 需要的 OAuth scopes
|
||
ErrorThreshold int // 授权错误阈值(对应 Chatwoot AUTHORIZATION_ERROR_THRESHOLD)
|
||
SupportsRefresh bool // 是否支持 Token 刷新
|
||
PKCERequired bool // 是否需要 PKCE
|
||
}
|
||
|
||
// OAuthTokenResult OAuth Token 交换/刷新结果
|
||
type OAuthTokenResult struct {
|
||
AccessToken string
|
||
RefreshToken string
|
||
ExpiresAt time.Time
|
||
Scope string
|
||
Extra map[string]interface{} // 额外字段(如 FB graph API version)
|
||
}
|
||
|
||
// ChannelCapabilities 渠道能力声明
|
||
// 对应 Chatwoot Inbox 的 feature_flags 和渠道特有能力
|
||
type ChannelCapabilities struct {
|
||
SupportsAttachments bool // 支持附件
|
||
SupportsLocation bool // 支持地理位置
|
||
SupportsAudio bool // 支持语音消息
|
||
SupportsVideo bool // 支持视频消息
|
||
SupportsTypingIndicator bool // 支持打字指示器
|
||
SupportsOnlineStatus bool // 支持在线状态推送
|
||
SupportsCSAT bool // 支持 CSAT 满意度调查
|
||
SupportsReplyTo bool // 支持回复引用
|
||
SupportsReaction bool // 支持表情回应
|
||
SupportsAutoAssignment bool // 支持自动分配
|
||
SupportsWorkingHours bool // 支持工作时间设置
|
||
MaxAttachmentSize int64 // 最大附件大小(字节)
|
||
MaxTextLength int // 最大文本长度
|
||
AttachmentTypes []string // 支持的附件 MIME 类型列表
|
||
}
|
||
|
||
// ReauthorizationStatus 重授权状态
|
||
// 对应 Chatwoot Reauthorizable 的 Redis 键状态
|
||
type ReauthorizationStatus struct {
|
||
Required bool
|
||
ErrorCount int
|
||
ErrorThreshold int
|
||
LastErrorTime time.Time
|
||
NotificationSent bool
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 4. ChannelRegistry注册机制
|
||
|
||
### 4.1 Registry 设计
|
||
|
||
```go
|
||
// pkg/channel/registry.go
|
||
|
||
package channel
|
||
|
||
import (
|
||
"fmt"
|
||
"sync"
|
||
)
|
||
|
||
// ChannelRegistry 渠道 Provider 全局注册表
|
||
// 单例模式,在 init() 阶段完成所有渠道的注册
|
||
// 对应 Chatwoot 通过 Rails 多态关联动态查找 Channel 的方式
|
||
//
|
||
// Go 版改进:
|
||
// - 注册表在编译期即可确定所有可用渠道(类型安全)
|
||
// - 通过配置文件可动态禁用某渠道(运行时控制)
|
||
// - 新增渠道只需:实现接口 → 注册 → 零修改核心代码
|
||
type ChannelRegistry struct {
|
||
providers map[ChannelType]ChannelProvider
|
||
oauthProviders map[ChannelType]OAuthProvider
|
||
pollingProviders map[ChannelType]PollingProvider
|
||
pushProviders map[ChannelType]PushProvider
|
||
enabledTypes map[ChannelType]bool // 渠道启用/禁用状态
|
||
mu sync.RWMutex
|
||
}
|
||
|
||
// globalRegistry 全局注册表实例
|
||
var globalRegistry = &ChannelRegistry{
|
||
providers: make(map[ChannelType]ChannelProvider),
|
||
oauthProviders: make(map[ChannelType]OAuthProvider),
|
||
pollingProviders: make(map[ChannelType]PollingProvider),
|
||
pushProviders: make(map[ChannelType]PushProvider),
|
||
enabledTypes: make(map[ChannelType]bool),
|
||
}
|
||
|
||
// Register 注册渠道 Provider
|
||
func Register(provider ChannelProvider) error {
|
||
globalRegistry.mu.Lock()
|
||
defer globalRegistry.mu.Unlock()
|
||
|
||
ct := provider.Type()
|
||
if _, exists := globalRegistry.providers[ct]; exists {
|
||
return fmt.Errorf("channel provider %s already registered", ct)
|
||
}
|
||
|
||
globalRegistry.providers[ct] = provider
|
||
globalRegistry.enabledTypes[ct] = true // 默认启用
|
||
|
||
// 检查并注册子接口
|
||
if op, ok := provider.(OAuthProvider); ok {
|
||
globalRegistry.oauthProviders[ct] = op
|
||
}
|
||
if pp, ok := provider.(PollingProvider); ok {
|
||
globalRegistry.pollingProviders[ct] = pp
|
||
}
|
||
if sp, ok := provider.(PushProvider); ok {
|
||
globalRegistry.pushProviders[ct] = sp
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// GetProvider 获取渠道 Provider
|
||
func GetProvider(channelType ChannelType) (ChannelProvider, error) {
|
||
globalRegistry.mu.RLock()
|
||
defer globalRegistry.mu.RUnlock()
|
||
|
||
p, ok := globalRegistry.providers[channelType]
|
||
if !ok {
|
||
return nil, fmt.Errorf("channel provider %s not registered", channelType)
|
||
}
|
||
if !globalRegistry.enabledTypes[channelType] {
|
||
return nil, fmt.Errorf("channel %s is disabled", channelType)
|
||
}
|
||
return p, nil
|
||
}
|
||
|
||
// GetOAuthProvider 获取 OAuth Provider(如果渠道支持 OAuth)
|
||
func GetOAuthProvider(channelType ChannelType) (OAuthProvider, error) {
|
||
globalRegistry.mu.RLock()
|
||
defer globalRegistry.mu.RUnlock()
|
||
|
||
op, ok := globalRegistry.oauthProviders[channelType]
|
||
if !ok {
|
||
return nil, fmt.Errorf("channel %s does not support OAuth", channelType)
|
||
}
|
||
return op, nil
|
||
}
|
||
|
||
// GetPollingProvider 获取 Polling Provider(如果渠道支持拉取)
|
||
func GetPollingProvider(channelType ChannelType) (PollingProvider, error) {
|
||
globalRegistry.mu.RLock()
|
||
defer globalRegistry.mu.RUnlock()
|
||
|
||
pp, ok := globalRegistry.pollingProviders[channelType]
|
||
if !ok {
|
||
return nil, fmt.Errorf("channel %s does not support polling", channelType)
|
||
}
|
||
return pp, nil
|
||
}
|
||
|
||
// EnableChannel 启用渠道
|
||
func EnableChannel(channelType ChannelType) {
|
||
globalRegistry.mu.Lock()
|
||
globalRegistry.enabledTypes[channelType] = true
|
||
globalRegistry.mu.Unlock()
|
||
}
|
||
|
||
// DisableChannel 禁用渠道
|
||
func DisableChannel(channelType ChannelType) {
|
||
globalRegistry.mu.Lock()
|
||
globalRegistry.enabledTypes[channelType] = false
|
||
globalRegistry.mu.Unlock()
|
||
}
|
||
|
||
// ListAvailableChannels 列出所有已注册且已启用的渠道
|
||
func ListAvailableChannels() []ChannelProvider {
|
||
globalRegistry.mu.RLock()
|
||
defer globalRegistry.mu.RUnlock()
|
||
|
||
result := make([]ChannelProvider, 0)
|
||
for ct, p := range globalRegistry.providers {
|
||
if globalRegistry.enabledTypes[ct] {
|
||
result = append(result, p)
|
||
}
|
||
}
|
||
return result
|
||
}
|
||
|
||
// AllRegisteredTypes 列出所有已注册的渠道类型(包含禁用的)
|
||
func AllRegisteredTypes() []ChannelType {
|
||
globalRegistry.mu.RLock()
|
||
defer globalRegistry.mu.RUnlock()
|
||
|
||
types := make([]ChannelType, 0, len(globalRegistry.providers))
|
||
for ct := range globalRegistry.providers {
|
||
types = append(types, ct)
|
||
}
|
||
return types
|
||
}
|
||
```
|
||
|
||
### 4.2 注册流程
|
||
|
||
```go
|
||
// pkg/channel/register.go — 各渠道 Provider 的注册入口
|
||
|
||
package channel
|
||
|
||
func init() {
|
||
// 第一阶段渠道 — 必须注册
|
||
_ = Register(&WebWidgetProvider{})
|
||
_ = Register(&TelegramProvider{})
|
||
|
||
// 第二阶段渠道 — 注册但可通过配置禁用
|
||
_ = Register(&FacebookProvider{})
|
||
_ = Register(&WhatsAppProvider{})
|
||
_ = Register(&EmailProvider{})
|
||
_ = Register(&TwilioSMSProvider{})
|
||
_ = Register(&TwilioWhatsAppProvider{})
|
||
_ = Register(&SMSProvider{})
|
||
_ = Register(&LineProvider{})
|
||
_ = Register(&APIProvider{})
|
||
_ = Register(&InstagramProvider{})
|
||
}
|
||
```
|
||
|
||
### 4.3 注册与查找流程对比
|
||
|
||
```
|
||
Chatwoot 查找流程:
|
||
Inbox.channel_type = "Channel::Telegram"
|
||
→ Rails 多态关联 inbox.channel → Channel::Telegram 模型实例
|
||
→ 调用 channel 的各种方法(duck typing,无显式接口)
|
||
|
||
GoChat 查找流程:
|
||
Inbox.ChannelType = "telegram"
|
||
→ ChannelRegistry.GetProvider("telegram") → TelegramProvider 实例
|
||
→ 调用 ChannelProvider 接口方法(编译期类型检查)
|
||
→ 如需 OAuth:ChannelRegistry.GetOAuthProvider("telegram") → 类型断言
|
||
```
|
||
|
||
---
|
||
|
||
## 5. 各渠道Provider实现设计
|
||
|
||
### 5.1 Web Widget Provider
|
||
|
||
```go
|
||
// pkg/channel/providers/web_widget.go
|
||
|
||
package providers
|
||
|
||
import (
|
||
"context"
|
||
"crypto/hmac"
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
|
||
"gochat/internal/model"
|
||
"gochat/pkg/channel"
|
||
)
|
||
|
||
type WebWidgetProvider struct{}
|
||
|
||
func (p *WebWidgetProvider) Type() channel.ChannelType { return channel.ChannelWebWidget }
|
||
func (p *WebWidgetProvider) Name() string { return "Web Widget" }
|
||
func (p *WebWidgetProvider) Description() string { return "网站嵌入式聊天 Widget" }
|
||
|
||
// ConfigSchema — 对应 Chatwoot Channel::WebWidget.EDITABLE_ATTRS
|
||
func (p *WebWidgetProvider) ConfigSchema() *channel.ConfigSchemaDefinition {
|
||
return &channel.ConfigSchemaDefinition{
|
||
Type: channel.ChannelWebWidget,
|
||
Fields: []channel.ConfigField{
|
||
{Name: "website_url", Type: "url", Label: "网站URL", Required: true},
|
||
{Name: "website_token", Type: "string", Label: "网站Token", Secret: true, Description: "自动生成,用于前端SDK标识"},
|
||
{Name: "hmac_token", Type: "string", Label: "HMAC Token", Secret: true, Description: "自动生成,用于身份验证"},
|
||
{Name: "widget_color", Type: "string", Label: "Widget颜色", Default: "#1f93ff"},
|
||
{Name: "welcome_title", Type: "string", Label: "欢迎标题"},
|
||
{Name: "welcome_tagline", Type: "string", Label: "欢迎副标题"},
|
||
{Name: "reply_time", Type: "enum", Label: "预计回复时间", EnumValues: []string{"in_a_few_minutes", "in_a_few_hours", "in_a_day"}},
|
||
{Name: "pre_chat_form_enabled", Type: "boolean", Label: "启用预聊天表单", Default: false},
|
||
{Name: "pre_chat_form_options", Type: "json", Label: "预聊天表单选项",
|
||
SubFields: []channel.ConfigField{
|
||
{Name: "pre_chat_message", Type: "string", Label: "预聊天消息"},
|
||
{Name: "require_email", Type: "boolean", Label: "需要邮箱"},
|
||
}},
|
||
{Name: "hmac_mandatory", Type: "boolean", Label: "强制HMAC验证", Default: false},
|
||
{Name: "allowed_domains", Type: "string", Label: "允许域名列表(逗号分隔)"},
|
||
{Name: "continuity_via_email", Type: "boolean", Label: "邮件回访", Default: true},
|
||
},
|
||
Required: []string{"website_url"},
|
||
Defaults: channel.ChannelConfig{
|
||
"widget_color": "#1f93ff",
|
||
"reply_time": "in_a_few_minutes",
|
||
"pre_chat_form_enabled": false,
|
||
"hmac_mandatory": false,
|
||
"continuity_via_email": true,
|
||
},
|
||
}
|
||
}
|
||
|
||
func (p *WebWidgetProvider) ValidateConfig(ctx context.Context, config channel.ChannelConfig) error {
|
||
// 验证 website_url 格式、allowed_domains 格式等
|
||
return nil // 具体实现略
|
||
}
|
||
|
||
func (p *WebWidgetProvider) DefaultConfig() channel.ChannelConfig {
|
||
return p.ConfigSchema().Defaults
|
||
}
|
||
|
||
func (p *WebWidgetProvider) OnCreate(ctx context.Context, inbox *model.Inbox, config channel.ChannelConfig) (channel.ChannelConfig, error) {
|
||
// 自动生成 website_token 和 hmac_token(对应 Chatwoot has_secure_token)
|
||
config["website_token"] = generateSecureToken(24)
|
||
config["hmac_token"] = generateSecureToken(24)
|
||
return config, nil
|
||
}
|
||
|
||
func (p *WebWidgetProvider) OnDestroy(ctx context.Context, inbox *model.Inbox, config channel.ChannelConfig) error {
|
||
// Web Widget 无需清理外部资源
|
||
return nil
|
||
}
|
||
|
||
// ProcessIncoming — Widget 前端通过 SDK 发送的消息
|
||
func (p *WebWidgetProvider) ProcessIncoming(ctx context.Context, inbox *model.Inbox, rawPayload []byte) (*channel.IncomingMessage, error) {
|
||
var widgetMsg WidgetMessagePayload
|
||
if err := json.Unmarshal(rawPayload, &widgetMsg); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// HMAC 验证(如果 hmac_mandatory)
|
||
if hmacMandatory, _ := config["hmac_mandatory"].(bool); hmacMandatory {
|
||
if !p.verifyHMAC(widgetMsg, config["hmac_token"].(string)) {
|
||
return nil, fmt.Errorf("HMAC verification failed")
|
||
}
|
||
}
|
||
|
||
return &channel.IncomingMessage{
|
||
Content: widgetMsg.Content,
|
||
ContentType: p.mapContentType(widgetMsg.ContentType),
|
||
SenderSourceID: widgetMsg.ContactIdentifier,
|
||
SenderName: widgetMsg.ContactName,
|
||
SenderType: channel.SenderContact,
|
||
ContactInboxSourceID: widgetMsg.ContactIdentifier,
|
||
Timestamp: widgetMsg.Timestamp,
|
||
Attachments: p.mapAttachments(widgetMsg.Attachments),
|
||
Extra: map[string]interface{}{"widget_session": widgetMsg.SessionID},
|
||
}, nil
|
||
}
|
||
|
||
func (p *WebWidgetProvider) ValidateWebhookRequest(ctx context.Context, inbox *model.Inbox, req *channel.WebhookRequest) error {
|
||
// Widget API 通过 website_token + HMAC token 验证
|
||
return nil
|
||
}
|
||
|
||
func (p *WebWidgetProvider) SendMessage(ctx context.Context, inbox *model.Inbox, message *model.Message, contact *model.Contact) (*channel.SendResult, error) {
|
||
// Web Widget 发送 = 通过 Redis Pub/Sub 推送到前端 SDK
|
||
// 无需调用外部 API,消息直接通过 WebSocket 推送到 Widget
|
||
return &channel.SendResult{
|
||
ExternalMessageID: fmt.Sprintf("widget_%d", message.ID),
|
||
Success: true,
|
||
Timestamp: time.Now(),
|
||
}, nil
|
||
}
|
||
|
||
func (p *WebWidgetProvider) GetContactProfile(ctx context.Context, inbox *model.Inbox, contactSource string) (*channel.ContactProfile, error) {
|
||
// Web Widget 的联系人资料来自预聊天表单(前端提交)
|
||
return nil, ErrContactProfileNotAvailable
|
||
}
|
||
|
||
func (p *WebWidgetProvider) Capabilities() channel.ChannelCapabilities {
|
||
return channel.ChannelCapabilities{
|
||
SupportsAttachments: true,
|
||
SupportsLocation: true,
|
||
SupportsAudio: true,
|
||
SupportsVideo: true,
|
||
SupportsTypingIndicator: true, // 实现 PushProvider
|
||
SupportsOnlineStatus: true, // 实现 PushProvider
|
||
SupportsCSAT: true,
|
||
SupportsReplyTo: false,
|
||
SupportsReaction: false,
|
||
SupportsAutoAssignment: true,
|
||
SupportsWorkingHours: true,
|
||
MaxAttachmentSize: 20 * 1024 * 1024, // 20MB
|
||
MaxTextLength: 0, // 无限制
|
||
AttachmentTypes: []string{"image/*", "audio/*", "video/*", "application/pdf"},
|
||
}
|
||
}
|
||
|
||
// === PushProvider 子接口实现 ===
|
||
|
||
func (p *WebWidgetProvider) PushTypingOn(ctx context.Context, inbox *model.Inbox, conversationID uint64) error {
|
||
// 通过 Redis Pub/Sub 发布 typing_on 事件
|
||
return publishWidgetEvent(ctx, inbox, "typing_on", conversationID)
|
||
}
|
||
|
||
func (p *WebWidgetProvider) PushTypingOff(ctx context.Context, inbox *model.Inbox, conversationID uint64) error {
|
||
return publishWidgetEvent(ctx, inbox, "typing_off", conversationID)
|
||
}
|
||
|
||
func (p *WebWidgetProvider) PushOnlineStatus(ctx context.Context, inbox *model.Inbox, agentAvailable bool) error {
|
||
return publishWidgetEvent(ctx, inbox, "agent_status", agentAvailable)
|
||
}
|
||
|
||
// Widget 特有数据结构
|
||
type WidgetMessagePayload struct {
|
||
Content string `json:"content"`
|
||
ContentType string `json:"content_type"`
|
||
ContactIdentifier string `json:"contact_identifier"`
|
||
ContactName string `json:"contact_name"`
|
||
SessionID string `json:"session_id"`
|
||
Timestamp time.Time `json:"timestamp"`
|
||
Attachments []WidgetAttachment `json:"attachments"`
|
||
HMACSignature string `json:"hmac_signature"`
|
||
}
|
||
```
|
||
|
||
### 5.2 Telegram Provider
|
||
|
||
```go
|
||
// pkg/channel/providers/telegram.go
|
||
|
||
package providers
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
|
||
"gochat/internal/model"
|
||
"gochat/pkg/channel"
|
||
)
|
||
|
||
type TelegramProvider struct{}
|
||
|
||
func (p *TelegramProvider) Type() channel.ChannelType { return channel.ChannelTelegram }
|
||
func (p *TelegramProvider) Name() string { return "Telegram" }
|
||
func (p *TelegramProvider) Description() string { return "通过 Telegram Bot API 接入" }
|
||
|
||
func (p *TelegramProvider) ConfigSchema() *channel.ConfigSchemaDefinition {
|
||
return &channel.ConfigSchemaDefinition{
|
||
Type: channel.ChannelTelegram,
|
||
Fields: []channel.ConfigField{
|
||
{Name: "bot_token", Type: "string", Label: "Bot Token", Required: true, Secret: true, Description: "Telegram Bot API Token"},
|
||
{Name: "bot_name", Type: "string", Label: "Bot 名称", Description: "自动从 API 获取"},
|
||
},
|
||
Required: []string{"bot_token"},
|
||
}
|
||
}
|
||
|
||
func (p *TelegramProvider) ValidateConfig(ctx context.Context, config channel.ChannelConfig) error {
|
||
botToken, _ := config["bot_token"].(string)
|
||
if botToken == "" {
|
||
return fmt.Errorf("bot_token is required")
|
||
}
|
||
// 调用 Telegram getMe API 验证 token 有效性
|
||
// 对应 Chatwoot Channel::Telegram.ensure_valid_bot_token
|
||
return validateTelegramBotToken(ctx, botToken)
|
||
}
|
||
|
||
func (p *WebWidgetProvider) OnCreate(ctx context.Context, inbox *model.Inbox, config channel.ChannelConfig) (channel.ChannelConfig, error) {
|
||
// 调用 Telegram setWebhook API 注册回调
|
||
// 对应 Chatwoot Channel::Telegram.setup_telegram_webhook
|
||
botToken := config["bot_token"].(string)
|
||
webhookURL := buildWebhookURL(channel.ChannelTelegram, botToken)
|
||
if err := setupTelegramWebhook(ctx, botToken, webhookURL); err != nil {
|
||
return nil, err
|
||
}
|
||
// 获取 bot 名称
|
||
botName, _ := getTelegramBotName(ctx, botToken)
|
||
config["bot_name"] = botName
|
||
return config, nil
|
||
}
|
||
|
||
func (p *TelegramProvider) OnDestroy(ctx context.Context, inbox *model.Inbox, config channel.ChannelConfig) error {
|
||
// 删除 Telegram Webhook
|
||
botToken := config["bot_token"].(string)
|
||
return deleteTelegramWebhook(ctx, botToken)
|
||
}
|
||
|
||
// ProcessIncoming — Telegram Bot API Webhook 回调
|
||
func (p *TelegramProvider) ProcessIncoming(ctx context.Context, inbox *model.Inbox, rawPayload []byte) (*channel.IncomingMessage, error) {
|
||
var update TelegramUpdate
|
||
if err := json.Unmarshal(rawPayload, &update); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// Telegram Update 可能是消息、回调、编辑等
|
||
msg := update.Message
|
||
if msg == nil {
|
||
return nil, ErrUnsupportedUpdateType
|
||
}
|
||
|
||
return &channel.IncomingMessage{
|
||
Content: msg.Text,
|
||
ContentType: model.ContentTypeText,
|
||
SourceID: fmt.Sprintf("%d", msg.MessageID),
|
||
SenderSourceID: fmt.Sprintf("%d", msg.From.ID),
|
||
SenderName: msg.From.FirstName + " " + msg.From.LastName,
|
||
SenderType: channel.SenderContact,
|
||
ConversationSourceID: fmt.Sprintf("%d", msg.Chat.ID),
|
||
ContactInboxSourceID: fmt.Sprintf("%d", msg.From.ID),
|
||
Timestamp: time.Unix(msg.Date, 0),
|
||
Attachments: p.mapTelegramAttachments(msg),
|
||
Extra: map[string]interface{}{
|
||
"chat_type": msg.Chat.Type,
|
||
"chat_id": msg.Chat.ID,
|
||
},
|
||
}, nil
|
||
}
|
||
|
||
func (p *TelegramProvider) ValidateWebhookRequest(ctx context.Context, inbox *model.Inbox, req *channel.WebhookRequest) error {
|
||
// Telegram Webhook 验证:通过 URL path 中的 bot_token 匹配
|
||
// Chatwoot: routes 中 /webhooks/telegram/{bot_token}
|
||
identifier := req.Identifier
|
||
botToken, _ := inbox.ChannelConfig["bot_token"].(string)
|
||
if identifier != botToken {
|
||
return fmt.Errorf("telegram webhook token mismatch")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (p *TelegramProvider) SendMessage(ctx context.Context, inbox *model.Inbox, message *model.Message, contact *model.Contact) (*channel.SendResult, error) {
|
||
botToken, _ := inbox.ChannelConfig["bot_token"].(string)
|
||
chatID, _ := getContactTelegramChatID(ctx, contact, inbox)
|
||
|
||
// 对应 Chatwoot Telegram.send_message_on_telegram
|
||
if message.Content != "" {
|
||
result, err := sendTelegramMessage(ctx, botToken, chatID, message.Content)
|
||
if err != nil {
|
||
return &channel.SendResult{Success: false, Error: err}, err
|
||
}
|
||
return &channel.SendResult{
|
||
ExternalMessageID: fmt.Sprintf("%d", result.MessageID),
|
||
Success: true,
|
||
Timestamp: time.Now(),
|
||
}, nil
|
||
}
|
||
|
||
// 附件发送
|
||
if len(message.Attachments) > 0 {
|
||
return sendTelegramAttachments(ctx, botToken, chatID, message.Attachments)
|
||
}
|
||
|
||
return &channel.SendResult{Success: true}, nil
|
||
}
|
||
|
||
func (p *TelegramProvider) GetContactProfile(ctx context.Context, inbox *model.Inbox, contactSource string) (*channel.ContactProfile, error) {
|
||
// 对应 Chatwoot Telegram.get_telegram_profile_image
|
||
botToken, _ := inbox.ChannelConfig["bot_token"].(string)
|
||
userID := parseTelegramUserID(contactSource)
|
||
profile, err := getTelegramUserProfile(ctx, botToken, userID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return profile, nil
|
||
}
|
||
|
||
func (p *TelegramProvider) Capabilities() channel.ChannelCapabilities {
|
||
return channel.ChannelCapabilities{
|
||
SupportsAttachments: true,
|
||
SupportsLocation: true,
|
||
SupportsAudio: true,
|
||
SupportsVideo: true,
|
||
SupportsTypingIndicator: false, // Telegram Bot API 不支持
|
||
SupportsOnlineStatus: false,
|
||
SupportsCSAT: false, // Telegram 不原生支持
|
||
SupportsReplyTo: true, // Telegram 支持 reply_to_message_id
|
||
SupportsReaction: false,
|
||
SupportsAutoAssignment: true,
|
||
SupportsWorkingHours: true,
|
||
MaxAttachmentSize: 50 * 1024 * 1024, // Telegram: 50MB
|
||
MaxTextLength: 4096,
|
||
AttachmentTypes: []string{"image/*", "audio/*", "video/*", "application/pdf"},
|
||
}
|
||
}
|
||
```
|
||
|
||
### 5.3 各渠道 Provider 特性矩阵
|
||
|
||
| 渠道 | ChannelProvider | OAuthProvider | PollingProvider | PushProvider | 关键实现差异 |
|
||
|------|:-:|:-:|:-:|:-:|------------|
|
||
| **Web Widget** | ✅ | — | — | ✅ | 消息发送=Redis Pub/Sub推送;HMAC验证;前端SDK |
|
||
| **Telegram** | ✅ | — | — | — | Bot API Webhook;token验证=getMe;chat_id映射 |
|
||
| **Facebook** | ✅ | ✅ | — | — | FB Graph API;OAuth + Page订阅;PSID映射 |
|
||
| **Instagram** | ✅ | ✅ | — | — | IG Messaging API;OAuth + Instagram-specific scope |
|
||
| **WhatsApp** | ✅ | ✅ | — | — | 360dialog/Business API;phone_number标识;模板消息 |
|
||
| **Email** | ✅ | — | ✅ | — | IMAP拉取+SMTP发送;邮件解析(MIME);thread关联 |
|
||
| **Twilio SMS** | ✅ | — | — | — | Twilio API;Messaging Service SID;SMS格式限制 |
|
||
| **Twilio WA** | ✅ | — | — | — | Twilio WhatsApp API;模板消息 |
|
||
| **SMS** | ✅ | — | — | — | 简易 SMS Provider(预留) |
|
||
| **Line** | ✅ | — | ✅ | — | Line Messaging API;需 polling 获取消息 |
|
||
| **API** | ✅ | — | — | — | 纯 API 渠道;无外部服务;最简 Provider |
|
||
|
||
### 5.4 第二阶段渠道 — 设计概要
|
||
|
||
**Facebook Provider** (`ChannelFacebook`):
|
||
|
||
```go
|
||
type FacebookProvider struct{}
|
||
// 实现 ChannelProvider + OAuthProvider
|
||
// OAuthConfig: FB Graph API, scopes=pages_messaging,pages_manage_metadata
|
||
// OnCreate: 订阅 FB Page + 设置 Webhook
|
||
// ProcessIncoming: 解析 FB Messaging Webhook (entry.messaging)
|
||
// SendMessage: FB Send API (POST /v18.0/me/messages)
|
||
// ValidateWebhookRequest: FB 签名验证 (X-Hub-Signature-256)
|
||
// RefreshToken: FB长期Token刷新(60天续期)
|
||
// CheckAuthorizationError: FB API error code 190 = token expired
|
||
// ErrorThreshold = 2
|
||
```
|
||
|
||
**WhatsApp Provider** (`ChannelWhatsApp`):
|
||
|
||
```go
|
||
type WhatsAppProvider struct{}
|
||
// 实现 ChannelProvider + OAuthProvider
|
||
// OAuthConfig: 360dialog API key获取
|
||
// ProcessIncoming: WhatsApp Business API Webhook
|
||
// SendMessage: WhatsApp Send API + 模板消息支持
|
||
// ValidateWebhookRequest: HMAC-SHA256签名验证
|
||
// ErrorThreshold = 2
|
||
```
|
||
|
||
**Email Provider** (`ChannelEmail`):
|
||
|
||
```go
|
||
type EmailProvider struct{}
|
||
// 实现 ChannelProvider + PollingProvider
|
||
// PollInterval: 5分钟(可配置)
|
||
// Poll: IMAP FetchService 拉取新邮件
|
||
// ProcessIncoming: MIME邮件解析 → IncomingMessage
|
||
// SendMessage: SMTP发送邮件回复
|
||
// ConfigSchema: IMAP/SMTP 连接参数 + 验证逻辑
|
||
```
|
||
|
||
---
|
||
|
||
## 6. Webhook回调统一处理
|
||
|
||
### 6.1 统一路由设计
|
||
|
||
Chatwoot 各渠道有独立的 Webhook Controller 和路由:
|
||
- `/webhooks/telegram/{bot_token}` → `Telegram::WebhooksController`
|
||
- `/webhooks/facebook/{page_id}` → `Facebook::WebhooksController`
|
||
- `/webhooks/whatsapp/{phone_number}` → `Whatsapp::WebhooksController`
|
||
- 等等...
|
||
|
||
GoChat 采用统一路由 + Provider 分发模式:
|
||
|
||
```go
|
||
// internal/handler/webhook.go
|
||
|
||
// 统一 Webhook 路由注册
|
||
func RegisterWebhookRoutes(r *gin.RouterGroup) {
|
||
// 统一入口:/webhooks/{channel_type}/{identifier}
|
||
// channel_type: telegram, facebook, whatsapp, twilio_sms, etc.
|
||
// identifier: 渠道标识(bot_token, page_id, phone_number 等)
|
||
r.POST("/webhooks/:channel_type/:identifier", HandleWebhook)
|
||
r.GET("/webhooks/:channel_type/:identifier", HandleWebhookVerification) // FB/Twilio 验证端点
|
||
}
|
||
```
|
||
|
||
### 6.2 Webhook 处理流程
|
||
|
||
```go
|
||
// internal/handler/webhook_handler.go
|
||
|
||
func HandleWebhook(c *gin.Context) {
|
||
channelType := channel.ChannelType(c.Param("channel_type"))
|
||
identifier := c.Param("identifier")
|
||
|
||
// 1. 获取 Provider
|
||
provider, err := channel.GetProvider(channelType)
|
||
if err != nil {
|
||
c.JSON(404, gin.H{"error": "unsupported channel type"})
|
||
return
|
||
}
|
||
|
||
// 2. 构建 WebhookRequest
|
||
body, _ := io.ReadAll(c.Request.Body)
|
||
req := &channel.WebhookRequest{
|
||
ChannelType: channelType,
|
||
Identifier: identifier,
|
||
Headers: extractHeaders(c.Request),
|
||
QueryParams: extractQueryParams(c.Request),
|
||
Body: body,
|
||
Method: c.Request.Method,
|
||
}
|
||
|
||
// 3. 查找对应的 Inbox
|
||
inbox, err := findInboxByChannelTypeAndIdentifier(channelType, identifier)
|
||
if err != nil {
|
||
c.JSON(404, gin.H{"error": "inbox not found"})
|
||
return
|
||
}
|
||
|
||
// 4. 验证 Webhook 请求真实性
|
||
if err := provider.ValidateWebhookRequest(c.Request.Context(), inbox, req); err != nil {
|
||
c.JSON(401, gin.H{"error": "webhook validation failed"})
|
||
return
|
||
}
|
||
|
||
// 5. 处理回调数据(可能包含多条消息)
|
||
result, err := provider.ProcessIncoming(c.Request.Context(), inbox, req.Body)
|
||
if err != nil {
|
||
c.JSON(500, gin.H{"error": "failed to process incoming message"})
|
||
return
|
||
}
|
||
|
||
// 6. 将 IncomingMessage 转入消息处理管道
|
||
// 创建/更新 Contact → 创建/找到 Conversation → 创建 Message
|
||
err = processIncomingMessage(c.Request.Context(), inbox, result)
|
||
if err != nil {
|
||
log.Errorf("failed to process incoming message: %v", err)
|
||
}
|
||
|
||
c.JSON(200, gin.H{"status": "ok"})
|
||
}
|
||
```
|
||
|
||
### 6.3 渠道特殊 Webhook 处理
|
||
|
||
部分渠道的 Webhook 有特殊逻辑:
|
||
|
||
| 渠道 | 特殊处理 | GoChat 实现 |
|
||
|------|---------|-------------|
|
||
| **Facebook** | GET 请求用于 Webhook 订阅验证(hub.mode=subscribe, hub.challenge) | `HandleWebhookVerification` 分支处理 |
|
||
| **Telegram** | Webhook URL path 包含 bot_token | identifier = bot_token |
|
||
| **WhatsApp** | 可能包含多条消息的 batch 回调 | ProcessIncoming 返回 `[]IncomingMessage` 方案需支持 |
|
||
| **Twilio** | Twilio Signature 验证(特定 header) | ValidateWebhookRequest 中实现 |
|
||
|
||
### 6.4 Webhook 验证策略对照
|
||
|
||
```go
|
||
// 各渠道 ValidateWebhookRequest 实现对照
|
||
|
||
// Telegram: URL path token 匹配
|
||
func (p *TelegramProvider) ValidateWebhookRequest(ctx, inbox, req) error {
|
||
return req.Identifier == inbox.ChannelConfig["bot_token"]
|
||
}
|
||
|
||
// Facebook: X-Hub-Signature-256 HMAC验证
|
||
func (p *FacebookProvider) ValidateWebhookRequest(ctx, inbox, req) error {
|
||
signature := req.Headers["X-Hub-Signature-256"]
|
||
appSecret := inbox.ChannelConfig["app_secret"]
|
||
expected := computeHMAC256(appSecret, req.Body)
|
||
return hmac.Equal(signature, expected)
|
||
}
|
||
|
||
// WhatsApp: HMAC-SHA256签名验证(类似 FB)
|
||
func (p *WhatsAppProvider) ValidateWebhookRequest(ctx, inbox, req) error {
|
||
signature := req.Headers["X-Hub-Signature-256"]
|
||
// 360dialog / Business API 签名验证
|
||
...
|
||
}
|
||
|
||
// WebWidget: website_token + HMAC验证
|
||
func (p *WebWidgetProvider) ValidateWebhookRequest(ctx, inbox, req) error {
|
||
token := req.QueryParams["website_token"]
|
||
return token == inbox.ChannelConfig["website_token"]
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 7. 渠道配置模型
|
||
|
||
### 7.1 GORM 模型设计
|
||
|
||
Chatwoot 每个渠道有独立的数据库表(`channel_web_widgets`, `channel_telegram`, `channel_facebook_page` 等),通过多态关联 `Inbox.channel_type + channel_id` 连接到 Inbox。
|
||
|
||
GoChat 采用**JSON 配置 + Provider 解析**方式,将渠道配置统一存储在 `Inbox.ChannelConfig` 字段中,减少数据库表数量,同时通过 `ConfigSchema()` 保证配置的结构化和可验证性。
|
||
|
||
> **设计权衡**:独立表 vs JSON 配置
|
||
> - Chatwoot 选择独立表:因为 Rails 的 `belongs_to :channel, polymorphic: true` 需要独立表承载各渠道差异字段
|
||
> - GoChat 选择 JSON 配置:Go 没有 Rails 多态关联的便利,独立表反而增加 ORM 映射复杂度;JSON 配置配合 Provider 的 ConfigSchema() 既保留了结构化验证,又减少了数据库维护成本
|
||
> - 如未来某渠道配置字段查询频率极高,可单独建立 GORM 模型作为投影表
|
||
|
||
```go
|
||
// internal/model/inbox.go
|
||
|
||
type Inbox struct {
|
||
ID uint64 `gorm:"primaryKey"`
|
||
AccountID uint64 `gorm:"not null;index"`
|
||
Name string `gorm:"not null"`
|
||
ChannelType string `gorm:"not null;index"` // "web_widget", "telegram", etc.
|
||
ChannelConfig datatypes.JSON `gorm:"type:jsonb"` // 渠道配置 JSON
|
||
GreetingEnabled bool `gorm:"default:false"`
|
||
GreetingMessage string
|
||
EnableAutoAssignment bool `gorm:"default:true"`
|
||
EnableEmailCollect bool `gorm:"default:true"`
|
||
CSATSurveyEnabled bool `gorm:"default:false"`
|
||
CSATConfig datatypes.JSON `gorm:"type:jsonb"`
|
||
WorkingHoursEnabled bool `gorm:"default:false"`
|
||
WorkingHours datatypes.JSON `gorm:"type:jsonb"` // WorkingHour配置
|
||
OutOfOfficeMessage string
|
||
AllowMessagesAfterResolved bool `gorm:"default:true"`
|
||
LockToSingleConversation bool `gorm:"default:false"`
|
||
SenderNameType string `gorm:"default:friendly"`
|
||
Timezone string `gorm:"default:UTC"`
|
||
AutoAssignmentConfig datatypes.JSON `gorm:"type:jsonb"`
|
||
BusinessName string
|
||
EmailAddress string
|
||
PortalID *uint64 `gorm:"index"`
|
||
AvatarURL string
|
||
|
||
// 关联
|
||
Account Account `gorm:"foreignKey:AccountID"`
|
||
InboxMembers []InboxMember `gorm:"foreignKey:InboxID"`
|
||
Conversations []Conversation `gorm:"foreignKey:InboxID"`
|
||
AgentBot *AgentBot `gorm:"foreignKey:InboxID"`
|
||
|
||
CreatedAt time.Time
|
||
UpdatedAt time.Time
|
||
}
|
||
|
||
// GetChannelConfig 解析 ChannelConfig JSON 为 map
|
||
func (i *Inbox) GetChannelConfig() (channel.ChannelConfig, error) {
|
||
var config channel.ChannelConfig
|
||
err := json.Unmarshal(i.ChannelConfig, &config)
|
||
return config, err
|
||
}
|
||
|
||
// SetChannelConfig 将 ChannelConfig map 序列化为 JSON
|
||
func (i *Inbox) SetChannelConfig(config channel.ChannelConfig) error {
|
||
data, err := json.Marshal(config)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
i.ChannelConfig = data
|
||
return nil
|
||
}
|
||
```
|
||
|
||
### 7.2 配置验证流程
|
||
|
||
```go
|
||
// internal/service/inbox_service.go
|
||
|
||
func CreateInbox(ctx context.Context, accountID uint64, params CreateInboxParams) (*model.Inbox, error) {
|
||
// 1. 获取渠道 Provider
|
||
provider, err := channel.GetProvider(channel.ChannelType(params.ChannelType))
|
||
if err != nil {
|
||
return nil, fmt.Errorf("unsupported channel type: %s", params.ChannelType)
|
||
}
|
||
|
||
// 2. 合并默认配置 + 用户配置
|
||
config := provider.DefaultConfig()
|
||
for k, v := range params.ChannelConfig {
|
||
config[k] = v
|
||
}
|
||
|
||
// 3. Provider 级配置验证
|
||
if err := provider.ValidateConfig(ctx, config); err != nil {
|
||
return nil, fmt.Errorf("config validation failed: %w", err)
|
||
}
|
||
|
||
// 4. OnCreate 回调(如生成 token、注册 webhook 等)
|
||
config, err = provider.OnCreate(ctx, nil, config) // inbox 尚未创建,传入 nil
|
||
if err != nil {
|
||
return nil, fmt.Errorf("on create callback failed: %w", err)
|
||
}
|
||
|
||
// 5. 创建 Inbox + ChannelConfig
|
||
inbox := &model.Inbox{
|
||
AccountID: accountID,
|
||
Name: params.Name,
|
||
ChannelType: params.ChannelType,
|
||
}
|
||
if err := inbox.SetChannelConfig(config); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// 6. GORM 持久化
|
||
if err := db.Create(ctx, inbox).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// 7. 创建 InboxMember(创建者自动成为成员)
|
||
...
|
||
|
||
return inbox, nil
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 8. 消息编解码架构
|
||
|
||
### 8.1 消息流转管道
|
||
|
||
```
|
||
外部渠道 → Webhook/Polling → Provider.ProcessIncoming()
|
||
↓
|
||
IncomingMessage (标准化)
|
||
↓
|
||
MessageProcessingPipeline
|
||
(Contact创建/匹配 → Conversation创建/匹配 → Message创建)
|
||
↓
|
||
model.Message (持久化)
|
||
↓
|
||
EventDispatcher → Redis Pub/Sub → 前端推送
|
||
↓
|
||
Provider.SendMessage() → 外部渠道
|
||
```
|
||
|
||
### 8.2 消息编解码器接口
|
||
|
||
```go
|
||
// pkg/channel/codec.go
|
||
|
||
// MessageCodec 消息编解码器接口
|
||
// 每个渠道可能需要特殊的编码/解码逻辑
|
||
type MessageCodec interface {
|
||
// EncodeOutbound 将内部 Message 编码为渠道发送格式
|
||
// 例如:WhatsApp 模板消息格式、Email MIME 格式
|
||
EncodeOutbound(ctx context.Context, message *model.Message, config ChannelConfig) (*OutboundPayload, error)
|
||
|
||
// DecodeInbound 将渠道原始数据解码为 IncomingMessage
|
||
// 通常由 ProcessIncoming 内部调用
|
||
DecodeInbound(ctx context.Context, rawPayload []byte, config ChannelConfig) (*IncomingMessage, error)
|
||
|
||
// FormatAttachment 格式化附件为渠道特定格式
|
||
FormatAttachment(ctx context.Context, attachment *model.Attachment, config ChannelConfig) (*AttachmentPayload, error)
|
||
}
|
||
|
||
type OutboundPayload struct {
|
||
ContentType string // "text", "template", "media", "email_mime"
|
||
Body []byte // 编码后的消息体
|
||
Headers map[string]string // 渠道特定 header
|
||
Params map[string]interface{} // 渠道特定参数
|
||
}
|
||
```
|
||
|
||
### 8.3 各渠道编解码差异
|
||
|
||
| 渠道 | 编入(Inbound) | 编出(Outbound) | 特殊处理 |
|
||
|------|-----------------|-------------------|----------|
|
||
| **Web Widget** | JSON → IncomingMessage | Message → JSON (Redis Pub/Sub) | HMAC签名、预聊天表单 |
|
||
| **Telegram** | Telegram Update JSON → IncomingMessage | Message → Telegram Send API JSON | reply_to_message_id、inline_keyboard |
|
||
| **Facebook** | FB Messaging Webhook JSON → IncomingMessage | Message → FB Send API JSON | PSID映射、模板消息 |
|
||
| **WhatsApp** | WA Webhook JSON → IncomingMessage | Message → WA Send API JSON + 模板 | 模板消息格式、HSM |
|
||
| **Email** | MIME邮件 → IncomingMessage | Message → MIME邮件 | Subject/Thread关联、HTML/plain |
|
||
| **Twilio SMS** | Twilio Webhook XML → IncomingMessage | Message → Twilio API | 短文本(160字符)、MMS |
|
||
|
||
### 8.4 消息内容类型映射
|
||
|
||
```go
|
||
// pkg/channel/content_type.go
|
||
|
||
// 消息内容类型 — 对应 Chatwoot MessageType enum + 渠道差异
|
||
type MessageContentType int
|
||
|
||
const (
|
||
ContentTypeText MessageContentType = 0 // 纯文本
|
||
ContentTypeImage MessageContentType = 1 // 图片
|
||
ContentTypeFile MessageContentType = 2 // 文件
|
||
ContentTypeAudio MessageContentType = 3 // 音频
|
||
ContentTypeVideo MessageContentType = 4 // 视频
|
||
ContentTypeLocation MessageContentType = 5 // 地理位置
|
||
ContentTypeTemplate MessageContentType = 6 // 模板消息(WhatsApp/Telegram)
|
||
ContentTypeEmail MessageContentType = 7 // 邮件
|
||
ContentTypeInput MessageContentType = 8 // 输入选择(Widget按钮等)
|
||
)
|
||
|
||
// 渠道→内容类型映射表
|
||
var channelContentTypeMap = map[ChannelType]map[string]MessageContentType{
|
||
ChannelTelegram: {
|
||
"text": ContentTypeText,
|
||
"photo": ContentTypeImage,
|
||
"document": ContentTypeFile,
|
||
"audio": ContentTypeAudio,
|
||
"video": ContentTypeVideo,
|
||
"location": ContentTypeLocation,
|
||
"sticker": ContentTypeImage,
|
||
"voice": ContentTypeAudio,
|
||
"animation":ContentTypeVideo,
|
||
"contact": ContentTypeInput,
|
||
},
|
||
ChannelFacebook: {
|
||
"text": ContentTypeText,
|
||
"image": ContentTypeImage,
|
||
"file": ContentTypeFile,
|
||
"audio": ContentTypeAudio,
|
||
"video": ContentTypeVideo,
|
||
"location":ContentTypeLocation,
|
||
"template":ContentTypeTemplate,
|
||
},
|
||
// ...各渠道映射
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 9. OAuth刷新与Reauthorization机制
|
||
|
||
### 9.1 Reauthorization 状态管理
|
||
|
||
Chatwoot 的 `Reauthorizable` concern 使用 Redis 存储授权错误计数和重授权标记。GoChat 将此机制集成到 `OAuthProvider` 子接口中,使用 Redis + GORM 组合管理状态。
|
||
|
||
```go
|
||
// internal/service/reauthorization.go
|
||
|
||
type ReauthorizationManager struct {
|
||
redis *redis.Client
|
||
db *gorm.DB
|
||
mailer MailerService
|
||
}
|
||
|
||
// RecordAuthError 记录授权错误
|
||
// 对应 Chatwoot Reauthorizable.authorization_error!
|
||
func (m *ReauthorizationManager) RecordAuthError(ctx context.Context, inbox *model.Inbox, provider OAuthProvider, apiError error) error {
|
||
// 1. 检查是否为授权错误
|
||
if !provider.CheckAuthorizationError(ctx, apiError) {
|
||
return nil // 不是授权错误,忽略
|
||
}
|
||
|
||
// 2. 递增错误计数(Redis)
|
||
countKey := fmt.Sprintf("auth_error:%s:%d", inbox.ChannelType, inbox.ID)
|
||
newCount, err := m.redis.Incr(ctx, countKey).Result()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// 3. 设置过期时间(24小时重置计数)
|
||
m.redis.Expire(ctx, countKey, 24*time.Hour)
|
||
|
||
// 4. 达到阈值 → 标记需要重新授权
|
||
threshold := provider.OAuthConfig().ErrorThreshold
|
||
if newCount >= int64(threshold) {
|
||
return m.PromptReauthorization(ctx, inbox, provider)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// PromptReauthorization 标记需要重新授权并通知管理员
|
||
// 对应 Chatwoot Reauthorizable.prompt_reauthorization!
|
||
func (m *ReauthorizationManager) PromptReauthorization(ctx context.Context, inbox *model.Inbox, provider OAuthProvider) error {
|
||
// 1. 设置 Redis 重授权标记
|
||
reauthKey := fmt.Sprintf("reauth_required:%s:%d", inbox.ChannelType, inbox.ID)
|
||
m.redis.Set(ctx, reauthKey, "true", 0) // 无过期,直到手动清除
|
||
|
||
// 2. 发邮件通知管理员
|
||
account, _ := getAccount(ctx, inbox.AccountID)
|
||
admins, _ := getAccountAdmins(ctx, account.ID)
|
||
for _, admin := range admins {
|
||
m.mailer.SendReauthorizationEmail(ctx, admin, inbox, provider)
|
||
}
|
||
|
||
// 3. 发布 INBOX_UPDATED 事件(包含 reauthorization_required 变化)
|
||
// 对应 Chatwoot Inbox.dispatch_reauthorization_event
|
||
dispatcher.Dispatch(ctx, EventInboxUpdated, EventData{
|
||
"inbox": inbox,
|
||
"reauthorization_required": true,
|
||
})
|
||
|
||
return nil
|
||
}
|
||
|
||
// IsReauthorizationRequired 检查是否需要重新授权
|
||
// 对应 Chatwoot Reauthorizable.reauthorization_required?
|
||
func (m *ReauthorizationManager) IsReauthorizationRequired(ctx context.Context, inbox *model.Inbox) bool {
|
||
reauthKey := fmt.Sprintf("reauth_required:%s:%d", inbox.ChannelType, inbox.ID)
|
||
val, _ := m.redis.Get(ctx, reauthKey).Result()
|
||
return val == "true"
|
||
}
|
||
|
||
// ClearReauthorization 清除重授权标记(重新授权成功后)
|
||
// 对应 Chatwoot Reauthorizable.reauthorized!
|
||
func (m *ReauthorizationManager) ClearReauthorization(ctx context.Context, inbox *model.Inbox) error {
|
||
reauthKey := fmt.Sprintf("reauth_required:%s:%d", inbox.ChannelType, inbox.ID)
|
||
countKey := fmt.Sprintf("auth_error:%s:%d", inbox.ChannelType, inbox.ID)
|
||
m.redis.Del(ctx, reauthKey, countKey)
|
||
|
||
// 发布 INBOX_UPDATED 事件
|
||
dispatcher.Dispatch(ctx, EventInboxUpdated, EventData{
|
||
"inbox": inbox,
|
||
"reauthorization_required": false,
|
||
})
|
||
|
||
return nil
|
||
}
|
||
```
|
||
|
||
### 9.2 OAuth Token 自动刷新
|
||
|
||
```go
|
||
// internal/service/oauth_refresh.go
|
||
|
||
// OAuthTokenRefresher OAuth Token 自动刷新服务
|
||
// 对应 Chatwoot 的 Instagram::RefreshOauthTokenService / Google::RefreshOauthTokenService
|
||
// 定时检查 Token 过期时间,自动刷新
|
||
|
||
type OAuthTokenRefresher struct {
|
||
redis *redis.Client
|
||
db *gorm.DB
|
||
registry *channel.ChannelRegistry
|
||
}
|
||
|
||
func (r *OAuthTokenRefresher) Start(ctx context.Context) {
|
||
ticker := time.NewTicker(1 * time.Hour) // 每小时检查一次
|
||
for {
|
||
select {
|
||
case <-ticker.C:
|
||
r.refreshExpiredTokens(ctx)
|
||
case <-ctx.Done():
|
||
ticker.Stop()
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
func (r *OAuthTokenRefresher) refreshExpiredTokens(ctx context.Context) {
|
||
// 查找所有 OAuth 渠道的 Inbox
|
||
oauthTypes := r.registry.OAuthChannelTypes()
|
||
for _, ct := range oauthTypes {
|
||
provider, _ := channel.GetOAuthProvider(ct)
|
||
inboxes, _ := findOAuthInboxes(ctx, ct)
|
||
|
||
for _, inbox := range inboxes {
|
||
config, _ := inbox.GetChannelConfig()
|
||
expiresAt, _ := parseTokenExpiry(config)
|
||
|
||
// Token 将在 1 小时内过期 → 小试刷新
|
||
if expiresAt.Before(time.Now().Add(1 * time.Hour)) {
|
||
newToken, err := provider.RefreshToken(ctx, inbox, config)
|
||
if err != nil {
|
||
log.Warnf("failed to refresh token for inbox %d: %v", inbox.ID, err)
|
||
// 记录授权错误
|
||
reauthMgr.RecordAuthError(ctx, inbox, provider, err)
|
||
continue
|
||
}
|
||
|
||
// 更新配置中的 Token
|
||
config["access_token"] = newToken.AccessToken
|
||
config["refresh_token"] = newToken.RefreshToken
|
||
config["token_expires_at"] = newToken.ExpiresAt
|
||
inbox.SetChannelConfig(config)
|
||
db.Save(ctx, inbox)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 10. Inbox聚合模型
|
||
|
||
### 10.1 Inbox 与 ChannelProvider 的关系
|
||
|
||
Inbox 是 GoChat 的核心聚合单元,一个 Inbox = 一个渠道入口 + 配置 + 成员列表。
|
||
|
||
```
|
||
Inbox (聚合根)
|
||
├── ChannelType: "telegram"
|
||
├── ChannelConfig: {"bot_token": "...", "bot_name": "..."} ← 渠道配置
|
||
├── Account: 账户
|
||
├── InboxMembers: [Agent1, Agent2] ← 可访问此 Inbox 的坐席
|
||
├── AgentBot: 可选的 AI Bot
|
||
├── Conversations: [Conv1, Conv2] ← 通过此 Inbox 进来的对话
|
||
├── WorkingHours: 配置 ← 工作时间
|
||
├── CSATConfig: 配置 ← 满意度调查
|
||
└── AutoAssignmentConfig: 配置 ← 自动分配策略
|
||
```
|
||
|
||
### 10.2 Inbox Service — 渠道无关操作
|
||
|
||
```go
|
||
// internal/service/inbox_service.go
|
||
|
||
type InboxService struct {
|
||
db *gorm.DB
|
||
registry *channel.ChannelRegistry
|
||
dispatcher EventDispatcher
|
||
}
|
||
|
||
// UpdateInbox 更新 Inbox 设置(渠道无关 + 渠道相关)
|
||
func (s *InboxService) UpdateInbox(ctx context.Context, inboxID uint64, params UpdateInboxParams) (*model.Inbox, error) {
|
||
inbox, err := s.findInbox(ctx, inboxID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// 更新 Inbox 级设置(渠道无关)
|
||
if params.Name != nil {
|
||
inbox.Name = *params.Name
|
||
}
|
||
if params.GreetingEnabled != nil {
|
||
inbox.GreetingEnabled = *params.GreetingEnabled
|
||
}
|
||
// ... 其他 Inbox 级字段
|
||
|
||
// 更新渠道级配置(渠道相关)
|
||
if params.ChannelConfigUpdates != nil {
|
||
provider, err := channel.GetProvider(channel.ChannelType(inbox.ChannelType))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
config, _ := inbox.GetChannelConfig()
|
||
for k, v := range params.ChannelConfigUpdates {
|
||
config[k] = v
|
||
}
|
||
|
||
// 渠道配置验证
|
||
if err := provider.ValidateConfig(ctx, config); err != nil {
|
||
return nil, fmt.Errorf("channel config validation failed: %w", err)
|
||
}
|
||
|
||
inbox.SetChannelConfig(config)
|
||
}
|
||
|
||
// 持久化
|
||
if err := s.db.Save(ctx, inbox).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// 审计日志
|
||
s.dispatcher.Dispatch(ctx, EventInboxUpdated, EventData{"inbox": inbox})
|
||
|
||
return inbox, nil
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 11. 第一阶段渠道实现优先级
|
||
|
||
### 11.1 实现路线图
|
||
|
||
| 优先级 | 渠道 | 接口实现 | 关键工作量 | 状态 |
|
||
|:------:|------|----------|-----------|------|
|
||
| **P0** | **Web Widget** | ChannelProvider + PushProvider | Widget SDK前端 + HMAC验证 + Redis推送 | 🔴 核心必做 |
|
||
| **P0** | **Telegram** | ChannelProvider | Bot API集成 + Webhook注册 + 消息发送 | 🔴 核心必做 |
|
||
| **P1** | **Email** | ChannelProvider + PollingProvider | IMAP/SMTP集成 + MIME解析 | 🟡 第二阶段 |
|
||
| **P1** | **WhatsApp** | ChannelProvider + OAuthProvider | 360dialog API + 模板消息 | 🟡 第二阶段 |
|
||
| **P2** | **Facebook** | ChannelProvider + OAuthProvider | FB Graph API + Page订阅 | 🔵 第三阶段 |
|
||
| **P2** | **API** | ChannelProvider | 最简实现 | 🔵 第三阶段 |
|
||
| **P3** | 其他渠道 | 各自组合 | 需评估需求 | ⚪ 暂缓 |
|
||
|
||
### 11.2 第一阶段验收标准
|
||
|
||
1. **ChannelProvider 接口定义完成**:所有接口方法签名确定,单元测试通过
|
||
2. **ChannelRegistry 注册机制完成**:init 注册 + 运行时查找 + 启用/禁用控制
|
||
3. **Web Widget Provider 实现完成**:
|
||
- ConfigSchema + DefaultConfig + ValidateConfig + OnCreate(自动生成 token)
|
||
- ProcessIncoming + SendMessage(Redis Pub/Sub 推送)
|
||
- PushProvider(typing indicator + online status)
|
||
4. **Telegram Provider 实现完成**:
|
||
- ConfigSchema + ValidateConfig(getMe验证)+ OnCreate(setWebhook)
|
||
- ProcessIncoming(Update解析)+ SendMessage(Bot API 发送)
|
||
- GetContactProfile(头像获取)
|
||
5. **统一 Webhook 处理完成**:单一入口 + Provider 分发 + 请求验证
|
||
6. **消息编解码完成**:IncomingMessage 标准化 + 内容类型映射
|
||
|
||
### 11.3 关键设计决策记录
|
||
|
||
| 决策 | 选择 | 原因 | 备注 |
|
||
|------|------|------|------|
|
||
| 渠道配置存储 | JSON 字段 vs 独立表 | **JSON 字段** | 减少表数量,Go 无 Rails 多态关联便利性;JSON Schema 保证结构化 |
|
||
| 渠道接口模式 | 显式 Go interface vs 隐式 duck typing | **显式 interface** | Go 强类型要求;编译期检查;新增渠道零修改核心代码 |
|
||
| OAuth 接口分离 | 子接口 vs 合入基础接口 | **子接口(OAuthProvider)** | 不是所有渠道需要 OAuth;组合优于继承 |
|
||
| Webhook 路由 | 统一入口 vs 独立路由 | **统一入口** | 减少路由维护;Provider 分发更灵活 |
|
||
| 消息发送 | Provider方法 vs 独立Service | **Provider.SendMessage()** | 统一管道;减少 Service 类数量 |
|
||
|
||
---
|
||
|
||
> **下一步文档**:07-design-auth-and-realtime |