348 lines
12 KiB
Go
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 ""
|
|
} |