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 "" }