Files
gochat/internal/channel/provider/web_widget.go
T
2026-06-04 15:44:48 +08:00

348 lines
12 KiB
Go

package provider
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/gochat/gochat/internal/channel"
"github.com/gochat/gochat/internal/model"
)
// WebWidgetProvider implements ChannelProvider for the web widget channel.
// Reference: Chatwoot app/models/channel/web_widget.rb
// + app/controllers/api/v1/accounts/channels/web_widgets_controller.rb
// + app/services/web_widget/send_on_web_widget_service.rb
//
// The Web Widget is the primary self-hosted chat widget that embeds on customer websites.
// Messages flow via WebSocket (ActionCable in Chatwoot → Redis Pub/Sub in GoChat).
// Outbound messages are sent through the WebSocket hub, not HTTP API.
type WebWidgetProvider struct{}
func NewWebWidgetProvider() *WebWidgetProvider {
return &WebWidgetProvider{}
}
func (p *WebWidgetProvider) Type() channel.ChannelType {
return channel.ChannelWebWidget
}
func (p *WebWidgetProvider) Name() string {
return "Web Widget"
}
func (p *WebWidgetProvider) Description() string {
return "Embed a live chat widget on your website to connect with visitors"
}
// === Configuration ===
func (p *WebWidgetProvider) ConfigSchema() *channel.ConfigSchemaDefinition {
return &channel.ConfigSchemaDefinition{
Type: "object",
Properties: map[string]channel.ConfigProperty{
"website_url": {
Type: "string",
Description: "The URL of the website where the widget will be embedded",
Format: "uri",
},
"widget_color": {
Type: "string",
Description: "Primary color for the widget UI (hex format)",
Pattern: "^#[0-9a-fA-F]{6}$",
Default: "#1f93ff",
},
"welcome_title": {
Type: "string",
Description: "Title shown on the widget welcome screen",
Default: "Hi there !",
},
"welcome_subtitle": {
Type: "string",
Description: "Subtitle shown on the widget welcome screen",
Default: "We make it simple and seamless for businesses to be customer centric.",
},
"agent_away_message": {
Type: "string",
Description: "Message shown when no agents are available",
Default: "We are away at the moment, please leave a message.",
},
"auto_resolve_duration": {
Type: "number",
Description: "Auto-resolve conversation after N minutes of inactivity (0 = disabled)",
Default: 0,
},
"continuity_via_email": {
Type: "boolean",
Description: "Allow conversation continuity via email when widget is closed",
Default: false,
},
"hmac_token": {
Type: "string",
Description: "HMAC verification token for widget identity verification",
Secret: true,
},
"reply_time": {
Type: "string",
Description: "Estimated reply time displayed on widget",
Enum: []string{"in_a_few_minutes", "in_a_few_hours", "in_a_day"},
Default: "in_a_few_minutes",
},
"pre_chat_form_enabled": {
Type: "boolean",
Description: "Require visitors to fill a pre-chat form before starting conversation",
Default: false,
},
"pre_chat_form_options": {
Type: "object",
Description: "Pre-chat form field configuration (JSON)",
},
"business_hours_enabled": {
Type: "boolean",
Description: "Show business availability hours on widget",
Default: false,
},
"offline_message_enabled": {
Type: "boolean",
Description: "Allow visitors to submit messages when agents are offline or outside business hours",
Default: true,
},
"offline_message_title": {
Type: "string",
Description: "Title shown on the offline message form",
Default: "Leave a message",
},
"offline_message_description": {
Type: "string",
Description: "Description shown below the offline message form title",
Default: "We will get back to you as soon as possible.",
},
},
Required: []string{"website_url"},
}
}
func (p *WebWidgetProvider) ValidateConfig(ctx context.Context, config channel.ChannelConfig) error {
websiteURL, ok := config["website_url"].(string)
if !ok || websiteURL == "" {
return fmt.Errorf("website_url is required")
}
if !strings.HasPrefix(websiteURL, "http://") && !strings.HasPrefix(websiteURL, "https://") {
return fmt.Errorf("website_url must be a valid URL")
}
return nil
}
func (p *WebWidgetProvider) DefaultConfig() channel.ChannelConfig {
return channel.ChannelConfig{
"website_url": "",
"widget_color": "#1f93ff",
"welcome_title": "Hi there !",
"welcome_subtitle": "We make it simple and seamless for businesses to be customer centric.",
"agent_away_message": "We are away at the moment, please leave a message.",
"auto_resolve_duration": 0,
"continuity_via_email": false,
"reply_time": "in_a_few_minutes",
"pre_chat_form_enabled": false,
"business_hours_enabled": false,
"offline_message_enabled": true,
"offline_message_title": "Leave a message",
"offline_message_description": "We will get back to you as soon as possible.",
}
}
// === Lifecycle ===
func (p *WebWidgetProvider) OnCreate(ctx context.Context, inbox *model.Inbox, config channel.ChannelConfig) (channel.ChannelConfig, error) {
// Generate HMAC token for widget identity verification
// Reference: Chatwoot's Channel::WebWidget before_create :set_hmac_token
hmacToken := generateHMACToken()
config["hmac_token"] = hmacToken
// Generate website token (identifier for widget embedding)
// Reference: Chatwoot's Channel::WebWidget website_token
websiteToken := generateWebsiteToken(inbox.ID)
config["website_token"] = websiteToken
config["identifier"] = websiteToken // used for webhook/WS routing
return config, nil
}
func (p *WebWidgetProvider) OnDestroy(ctx context.Context, inbox *model.Inbox, config channel.ChannelConfig) error {
// Web widget cleanup: just mark inbox as disabled
// No external resources to clean up (no webhook, no API subscription)
return nil
}
// === Inbound ===
func (p *WebWidgetProvider) ProcessIncoming(ctx context.Context, inbox *model.Inbox, rawPayload []byte) (*channel.IncomingMessage, error) {
// Web widget messages come via WebSocket (not HTTP webhook)
// The raw payload is the JSON sent from the widget client
// Reference: Chatwoot's IncomingMessageService for WebWidget
var widgetMsg WebWidgetMessage
if err := json.Unmarshal(rawPayload, &widgetMsg); err != nil {
return nil, fmt.Errorf("failed to parse web widget message: %w", err)
}
incoming := &channel.IncomingMessage{
ChannelType: channel.ChannelWebWidget,
SourceID: widgetMsg.MessageID, // client-generated or server-assigned
ConversationID: widgetMsg.ConversationID,
SenderID: widgetMsg.ContactID, // widget contact identifier
SenderName: widgetMsg.ContactName,
SenderType: channel.SenderContact,
InboxID: inbox.ID,
AccountID: inbox.AccountID,
ReceivedAt: time.Now(),
ContentType: channel.ContentText,
Content: widgetMsg.Content,
Extra: channel.ChannelConfig{
"browser": widgetMsg.BrowserInfo,
"page_url": widgetMsg.PageURL,
"referer": widgetMsg.Referer,
"identifier": widgetMsg.Identifier, // HMAC-verified contact identifier
},
}
// Handle attachments (file uploads from widget)
if len(widgetMsg.Attachments) > 0 {
incoming.Attachments = make([]channel.Attachment, len(widgetMsg.Attachments))
for i, att := range widgetMsg.Attachments {
incoming.Attachments[i] = channel.Attachment{
URL: att.URL,
ContentType: att.ContentType,
Filename: att.Filename,
FileSize: att.FileSize,
}
}
}
return incoming, nil
}
func (p *WebWidgetProvider) ValidateWebhookRequest(ctx context.Context, inbox *model.Inbox, request *channel.WebhookRequest) error {
// Web widget doesn't use HTTP webhooks — messages come via WebSocket
// But we still implement this for HMAC verification on the widget API endpoint
// Reference: Chatwoot's widget API HMAC token verification
hmacToken := request.Headers["X-Hmac-Token"]
if hmacToken == "" {
// First connection — no HMAC required for new visitors
return nil
}
// Verify HMAC against stored token
// Reference: Chatwoot's message_hmac_verification concern
// TODO: implement actual HMAC verification using stored token (P6)
return nil
}
// === Outbound ===
func (p *WebWidgetProvider) SendMessage(ctx context.Context, inbox *model.Inbox, message *model.Message, contact *model.Contact) (*channel.SendResult, error) {
// Web widget outbound messages go via WebSocket hub, not HTTP API
// Reference: Chatwoot's ActionCable broadcast → GoChat's Redis Pub/Sub
// The actual sending is handled by the WebSocket hub (P9 implementation)
//
// This method is called by the message pipeline to prepare the outbound message,
// then the WebSocket hub delivers it to connected widget clients.
// For now, we return success — the actual WebSocket delivery happens via event bus
return &channel.SendResult{
ExternalID: fmt.Sprintf("widget_%d_%d", inbox.ID, message.ID),
DeliveredAt: time.Now(),
Extra: channel.ChannelConfig{
"delivery_method": "websocket",
"website_token": configFromWidgetInbox(inbox, "website_token"),
},
}, nil
}
// === Contact ===
func (p *WebWidgetProvider) GetContactProfile(ctx context.Context, inbox *model.Inbox, contactSource string) (*channel.ContactProfile, error) {
// Web widget contacts don't have external profiles
// Their info comes from the pre-chat form or browser metadata
// Reference: Chatwoot's contact creation from widget identifier
return &channel.ContactProfile{
Name: contactSource, // identifier-based name
Extra: channel.ChannelConfig{
"source": "web_widget",
},
}, nil
}
// === Capabilities ===
func (p *WebWidgetProvider) Capabilities() channel.ChannelCapabilities {
return channel.ChannelCapabilities{
SupportsAttachments: true, // file upload in widget
SupportsLocation: false,
SupportsTypingIndicator: true, // Chatwoot widget supports typing indicator
SupportsDeliveryStatus: false,
SupportsReplies: false,
SupportsEmojiReactions: false,
SupportsVoiceMessages: false,
SupportsVideoCalls: false,
SupportsCustomCards: false,
SupportsTemplates: false,
SupportsEmailHeaders: false,
MaxAttachmentSize: 10 * 1024 * 1024, // 10MB
MaxTextLength: 0, // unlimited (internal)
}
}
// init registers WebWidgetProvider with the global channel registry.
func init() {
channel.MustRegister(NewWebWidgetProvider())
}
// --- Web Widget Message Types ---
// WebWidgetMessage is the JSON payload sent from the widget client.
// Reference: Chatwoot's widget_api/messages_controller create action
type WebWidgetMessage struct {
MessageID string `json:"message_id,omitempty"`
ConversationID string `json:"conversation_id,omitempty"`
ContactID string `json:"contact_id,omitempty"`
ContactName string `json:"contact_name,omitempty"`
Content string `json:"content"`
ContentType string `json:"content_type,omitempty"` // text, input_select, file
Attachments []WidgetAttachment `json:"attachments,omitempty"`
Identifier string `json:"identifier,omitempty"` // HMAC-verified contact ID
BrowserInfo string `json:"browser_info,omitempty"`
PageURL string `json:"page_url,omitempty"`
Referer string `json:"referer,omitempty"`
}
// WidgetAttachment is an attachment in a web widget message.
type WidgetAttachment struct {
URL string `json:"url"`
ContentType string `json:"content_type"`
Filename string `json:"filename,omitempty"`
FileSize int64 `json:"file_size,omitempty"`
}
// --- Helper functions ---
func generateHMACToken() string {
// Generate a random HMAC token for widget identity verification
// Reference: Chatwoot's set_hmac_token callback
// TODO: use crypto/rand for proper token generation (P6)
return fmt.Sprintf("hmac_%d", time.Now().UnixNano())
}
func generateWebsiteToken(inboxID uint) string {
// Generate a website token for widget embedding
// Reference: Chatwoot's Channel::WebWidget website_token
return fmt.Sprintf("wt_%d_%d", inboxID, time.Now().UnixNano())
}
func configFromWidgetInbox(inbox *model.Inbox, key string) string {
// TODO: integrate with GORM repository for config lookup (P6)
return ""
}