清理: - 删除 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 相关修改
63 KiB
P2D — GoChat 渠道抽象层设计
版本:v1.1 | 更新日期:2026-07-09 参照:Chatwoot v3.x
Channelableconcern + 多态 Channel 模型 需求基线:M2-Inbox与渠道管理 + M8-通知与Webhook 状态:设计文档,渠道抽象已落地。当前 9 个 Provider 实现(WebWidget/Telegram/Email/Facebook/Instagram/WhatsApp/Twilio/LINE/TikTok)。
目录
- 设计哲学与目标
- 与Chatwoot Channelable对比分析
- ChannelProvider核心接口定义
- ChannelRegistry注册机制
- 各渠道Provider实现设计
- Webhook回调统一处理
- 渠道配置模型
- 消息编解码架构
- OAuth刷新与Reauthorization机制
- Inbox聚合模型
- 第一阶段渠道实现优先级
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 原实现
# 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 — 基础接口
// 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渠道子接口
// 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 — 主动拉取渠道子接口
// 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 — 推送通知渠道子接口
// 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 核心数据结构
// 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 设计
// 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 注册流程
// 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
// 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
// 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映射 |
| ✅ | ✅ | — | — | FB Graph API;OAuth + Page订阅;PSID映射 | |
| ✅ | ✅ | — | — | IG Messaging API;OAuth + Instagram-specific scope | |
| ✅ | ✅ | — | — | 360dialog/Business API;phone_number标识;模板消息 | |
| ✅ | — | ✅ | — | 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):
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):
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):
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 分发模式:
// 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 处理流程
// 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 实现 |
|---|---|---|
| GET 请求用于 Webhook 订阅验证(hub.mode=subscribe, hub.challenge) | HandleWebhookVerification 分支处理 |
|
| Telegram | Webhook URL path 包含 bot_token | identifier = bot_token |
| 可能包含多条消息的 batch 回调 | ProcessIncoming 返回 []IncomingMessage 方案需支持 |
|
| Twilio | Twilio Signature 验证(特定 header) | ValidateWebhookRequest 中实现 |
6.4 Webhook 验证策略对照
// 各渠道 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 模型作为投影表
// 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 配置验证流程
// 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 消息编解码器接口
// 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 |
| FB Messaging Webhook JSON → IncomingMessage | Message → FB Send API JSON | PSID映射、模板消息 | |
| WA Webhook JSON → IncomingMessage | Message → WA Send API JSON + 模板 | 模板消息格式、HSM | |
| MIME邮件 → IncomingMessage | Message → MIME邮件 | Subject/Thread关联、HTML/plain | |
| Twilio SMS | Twilio Webhook XML → IncomingMessage | Message → Twilio API | 短文本(160字符)、MMS |
8.4 消息内容类型映射
// 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 组合管理状态。
// 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 自动刷新
// 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 — 渠道无关操作
// 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 | ChannelProvider + PollingProvider | IMAP/SMTP集成 + MIME解析 | 🟡 第二阶段 | |
| P1 | ChannelProvider + OAuthProvider | 360dialog API + 模板消息 | 🟡 第二阶段 | |
| P2 | ChannelProvider + OAuthProvider | FB Graph API + Page订阅 | 🔵 第三阶段 | |
| P2 | API | ChannelProvider | 最简实现 | 🔵 第三阶段 |
| P3 | 其他渠道 | 各自组合 | 需评估需求 | ⚪ 暂缓 |
11.2 第一阶段验收标准
- ChannelProvider 接口定义完成:所有接口方法签名确定,单元测试通过
- ChannelRegistry 注册机制完成:init 注册 + 运行时查找 + 启用/禁用控制
- Web Widget Provider 实现完成:
- ConfigSchema + DefaultConfig + ValidateConfig + OnCreate(自动生成 token)
- ProcessIncoming + SendMessage(Redis Pub/Sub 推送)
- PushProvider(typing indicator + online status)
- Telegram Provider 实现完成:
- ConfigSchema + ValidateConfig(getMe验证)+ OnCreate(setWebhook)
- ProcessIncoming(Update解析)+ SendMessage(Bot API 发送)
- GetContactProfile(头像获取)
- 统一 Webhook 处理完成:单一入口 + Provider 分发 + 请求验证
- 消息编解码完成: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