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