Files
gochat/docs/product/06-design-channel-abstraction.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

1694 lines
63 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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