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

63 KiB
Raw Permalink Blame History

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. 设计哲学与目标
  2. 与Chatwoot Channelable对比分析
  3. ChannelProvider核心接口定义
  4. ChannelRegistry注册机制
  5. 各渠道Provider实现设计
  6. Webhook回调统一处理
  7. 渠道配置模型
  8. 消息编解码架构
  9. OAuth刷新与Reauthorization机制
  10. Inbox聚合模型
  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 原实现

# 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 ControllerTelegram::WebhooksController等) 无统一入站管道
发消息 各渠道独立 ServiceTelegram::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 接口方法(编译期类型检查)
  → 如需 OAuthChannelRegistry.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 Webhooktoken验证=getMechat_id映射
Facebook FB Graph APIOAuth + Page订阅;PSID映射
Instagram IG Messaging APIOAuth + Instagram-specific scope
WhatsApp 360dialog/Business APIphone_number标识;模板消息
Email IMAP拉取+SMTP发送;邮件解析(MIME)thread关联
Twilio SMS Twilio APIMessaging Service SIDSMS格式限制
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 实现
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 验证策略对照

// 各渠道 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
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 消息内容类型映射

// 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 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 + SendMessageRedis Pub/Sub 推送)
    • PushProvidertyping indicator + online status
  4. Telegram Provider 实现完成
    • ConfigSchema + ValidateConfiggetMe验证)+ OnCreatesetWebhook
    • ProcessIncomingUpdate解析)+ SendMessageBot 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