diff --git a/wuzapi-chatwoot-integration/.env.sample b/wuzapi-chatwoot-integration/.env.sample new file mode 100644 index 00000000..040e8a72 --- /dev/null +++ b/wuzapi-chatwoot-integration/.env.sample @@ -0,0 +1,15 @@ +WUZAPI_BASE_URL= +WUZAPI_API_KEY= +WUZAPI_INSTANCE_ID= +WUZAPI_WEBHOOK_URL_CHATWOOT= +CHATWOOT_BASE_URL= +CHATWOOT_ACCESS_TOKEN= +CHATWOOT_ACCOUNT_ID= +CHATWOOT_INBOX_ID= +WEBHOOK_SECRET= +REDIS_URL= +DATABASE_URL=./wuzapi_chatwoot.db +PORT=8080 +LOG_LEVEL=info +LOG_FORMAT=console +WUZAPI_WEBHOOK_PATH=/webhooks/wuzapi diff --git a/wuzapi-chatwoot-integration/Dockerfile b/wuzapi-chatwoot-integration/Dockerfile new file mode 100644 index 00000000..7bed70fa --- /dev/null +++ b/wuzapi-chatwoot-integration/Dockerfile @@ -0,0 +1 @@ +# Placeholder Dockerfile diff --git a/wuzapi-chatwoot-integration/README.md b/wuzapi-chatwoot-integration/README.md new file mode 100644 index 00000000..76113e6a --- /dev/null +++ b/wuzapi-chatwoot-integration/README.md @@ -0,0 +1,3 @@ +# Wuzapi-Chatwoot Integration + +This project integrates Wuzapi with Chatwoot. diff --git a/wuzapi-chatwoot-integration/config/config.go b/wuzapi-chatwoot-integration/config/config.go new file mode 100644 index 00000000..14ae5b22 --- /dev/null +++ b/wuzapi-chatwoot-integration/config/config.go @@ -0,0 +1,74 @@ +package config + +import ( + // "fmt" // No longer needed + "os" + + "github.com/joho/godotenv" + "github.com/rs/zerolog/log" // Use global logger +) + +// Config holds all configuration fields for the application. +type Config struct { + WuzapiBaseURL string + WuzapiAPIKey string + WuzapiInstanceID string + WuzapiWebhookURLChatwoot string + ChatwootBaseURL string + ChatwootAccessToken string + ChatwootAccountID string + ChatwootInboxID string + WebhookSecret string + RedisURL string + DatabaseURL string + Port string + LogLevel string + LogFormat string // Added to control log format (e.g., "console" or "json") + WuzapiWebhookPath string // Path for incoming Wuzapi webhooks +} + +// LoadConfig loads configuration from environment variables. +// It attempts to load a .env file if present. +func LoadConfig() (*Config, error) { + // Attempt to load .env file, but don't fail if it's not present. + // Environment variables will take precedence. + err := godotenv.Load() + if err != nil { + log.Info().Err(err).Msg("No .env file found or error loading it, relying on environment variables") + } else { + log.Info().Msg("Loaded configuration from .env file (if present)") + } + + log.Info().Msg("Loading configuration from environment variables...") + + cfg := &Config{ + WuzapiBaseURL: os.Getenv("WUZAPI_BASE_URL"), + WuzapiAPIKey: os.Getenv("WUZAPI_API_KEY"), + WuzapiInstanceID: os.Getenv("WUZAPI_INSTANCE_ID"), + WuzapiWebhookURLChatwoot: os.Getenv("WUZAPI_WEBHOOK_URL_CHATWOOT"), + ChatwootBaseURL: os.Getenv("CHATWOOT_BASE_URL"), + ChatwootAccessToken: os.Getenv("CHATWOOT_ACCESS_TOKEN"), + ChatwootAccountID: os.Getenv("CHATWOOT_ACCOUNT_ID"), + ChatwootInboxID: os.Getenv("CHATWOOT_INBOX_ID"), + WebhookSecret: os.Getenv("WEBHOOK_SECRET"), + RedisURL: os.Getenv("REDIS_URL"), + DatabaseURL: os.Getenv("DATABASE_URL"), + Port: os.Getenv("PORT"), + LogLevel: os.Getenv("LOG_LEVEL"), + LogFormat: os.Getenv("LOG_FORMAT"), + WuzapiWebhookPath: os.Getenv("WUZAPI_WEBHOOK_PATH"), + } + + if cfg.WuzapiWebhookPath == "" { + cfg.WuzapiWebhookPath = "/webhooks/wuzapi" // Default path + log.Info().Str("path", cfg.WuzapiWebhookPath).Msg("WUZAPI_WEBHOOK_PATH not set, using default") + } + + // In a real application, you would validate these values. + // For debugging, you might log these, but be careful with sensitive data. + // Example: log.Debug().Str("wuzapi_base_url", cfg.WuzapiBaseURL).Msg("Config value") + // Omitting individual value logging here for brevity and security. + + log.Info().Msg("Configuration loading attempt complete.") + return cfg, nil +} diff --git a/wuzapi-chatwoot-integration/go.mod b/wuzapi-chatwoot-integration/go.mod new file mode 100644 index 00000000..bef98dea --- /dev/null +++ b/wuzapi-chatwoot-integration/go.mod @@ -0,0 +1,22 @@ +module wuzapi-chatwoot-integration + +go 1.23.1 + +require ( + github.com/go-resty/resty/v2 v2.16.5 + github.com/joho/godotenv v1.5.1 + github.com/rs/zerolog v1.34.0 + gorm.io/driver/sqlite v1.6.0 + gorm.io/gorm v1.30.0 +) + +require ( + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.19 // indirect + github.com/mattn/go-sqlite3 v1.14.22 // indirect + golang.org/x/net v0.33.0 // indirect + golang.org/x/sys v0.28.0 // indirect + golang.org/x/text v0.21.0 // indirect +) diff --git a/wuzapi-chatwoot-integration/go.sum b/wuzapi-chatwoot-integration/go.sum new file mode 100644 index 00000000..526a5d6e --- /dev/null +++ b/wuzapi-chatwoot-integration/go.sum @@ -0,0 +1,36 @@ +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/go-resty/resty/v2 v2.16.5 h1:hBKqmWrr7uRc3euHVqmh1HTHcKn99Smr7o5spptdhTM= +github.com/go-resty/resty/v2 v2.16.5/go.mod h1:hkJtXbA2iKHzJheXYvQ8snQES5ZLGKMwQ07xAwp/fiA= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= +github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= +golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= +golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= +gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs= +gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= diff --git a/wuzapi-chatwoot-integration/internal/adapters/chatwoot/client.go b/wuzapi-chatwoot-integration/internal/adapters/chatwoot/client.go new file mode 100644 index 00000000..24c4c195 --- /dev/null +++ b/wuzapi-chatwoot-integration/internal/adapters/chatwoot/client.go @@ -0,0 +1,224 @@ +package chatwoot + +import ( + "fmt" + "time" + + "github.com/go-resty/resty/v2" + "github.com/rs/zerolog/log" +) + +// Client struct holds the configuration for the Chatwoot client. +type Client struct { + httpClient *resty.Client + baseURL string + accessToken string + accountID string + inboxID string // Keep inboxID if it's frequently used in requests, or pass as param +} + +// NewClient creates a new Chatwoot client. +// The inboxID is included here for convenience if most operations target a specific inbox. +func NewClient(baseURL, accessToken, accountID, inboxID string) (*Client, error) { + if baseURL == "" { + return nil, fmt.Errorf("Chatwoot baseURL cannot be empty") + } + if accessToken == "" { + return nil, fmt.Errorf("Chatwoot accessToken cannot be empty") + } + if accountID == "" { + return nil, fmt.Errorf("Chatwoot accountID cannot be empty") + } + // inboxID might be optional at client level if methods will specify it + if inboxID == "" { + return nil, fmt.Errorf("Chatwoot inboxID cannot be empty for this client setup") + } + + client := resty.New(). + SetBaseURL(baseURL). + SetHeader("api_access_token", accessToken). // Common header for Chatwoot + SetTimeout(10 * time.Second) + + log.Info().Str("baseURL", baseURL).Str("accountID", accountID).Str("inboxID", inboxID).Msg("Chatwoot client configured") + + return &Client{ + httpClient: client, + baseURL: baseURL, + accessToken: accessToken, + accountID: accountID, + inboxID: inboxID, + }, nil +} + +// CreateContact creates a new contact in Chatwoot. +func (c *Client) CreateContact(payload ChatwootContactPayload) (*ChatwootContact, error) { + url := fmt.Sprintf("/api/v1/accounts/%s/contacts", c.accountID) + + resp, err := c.httpClient.R(). + SetBody(payload). + SetResult(&ChatwootContact{}). // Expecting direct contact object, not nested like {"payload": {...}} + Post(url) + + if err != nil { + log.Error().Err(err).Str("url", url).Interface("payload", payload).Msg("Chatwoot API: CreateContact request failed") + return nil, fmt.Errorf("Chatwoot API CreateContact request failed: %w", err) + } + + if resp.IsError() { + log.Error().Str("url", url).Interface("payload", payload).Int("statusCode", resp.StatusCode()).Str("responseBody", string(resp.Body())).Msg("Chatwoot API: CreateContact returned an error") + return nil, fmt.Errorf("Chatwoot API CreateContact error: status %s, body: %s", resp.Status(), resp.String()) + } + + contact := resp.Result().(*ChatwootContact) + log.Info().Int("contactID", contact.ID).Str("phoneNumber", contact.PhoneNumber).Msg("Successfully created Chatwoot contact") + return contact, nil +} + +// GetContactByPhone searches for a contact by phone number. +// Note: Chatwoot's search is a general query 'q'. If 'phone_number' is not a unique indexed field for search, +// this might return multiple contacts if other fields match the number. +// For exact match on phone number, Chatwoot might require a filter if available, or this function needs to iterate. +func (c *Client) GetContactByPhone(phoneNumber string) (*ChatwootContact, error) { + url := fmt.Sprintf("/api/v1/accounts/%s/contacts/search", c.accountID) + + var searchResult ChatwootContactSearchPayload // Expects {"payload": [...]} + resp, err := c.httpClient.R(). + SetQueryParam("q", phoneNumber). + SetResult(&searchResult). + Get(url) + + if err != nil { + log.Error().Err(err).Str("url", url).Str("phoneNumber", phoneNumber).Msg("Chatwoot API: GetContactByPhone request failed") + return nil, fmt.Errorf("Chatwoot API GetContactByPhone request failed: %w", err) + } + + if resp.IsError() { + log.Error().Str("url", url).Str("phoneNumber", phoneNumber).Int("statusCode", resp.StatusCode()).Str("responseBody", string(resp.Body())).Msg("Chatwoot API: GetContactByPhone returned an error") + return nil, fmt.Errorf("Chatwoot API GetContactByPhone error: status %s, body: %s", resp.Status(), resp.String()) + } + + // Iterate through search results to find an exact match for the phone number. + // Chatwoot search can be broad. + for _, contact := range searchResult.Payload { + if contact.PhoneNumber == phoneNumber { + log.Info().Int("contactID", contact.ID).Str("phoneNumber", phoneNumber).Msg("Found Chatwoot contact by phone number") + return &contact, nil + } + } + + log.Info().Str("phoneNumber", phoneNumber).Msg("No Chatwoot contact found with this exact phone number") + return nil, nil // Contact not found +} + +// CreateConversation creates a new conversation in Chatwoot. +func (c *Client) CreateConversation(payload ChatwootConversationPayload) (*ChatwootConversation, error) { + url := fmt.Sprintf("/api/v1/accounts/%s/conversations", c.accountID) + + resp, err := c.httpClient.R(). + SetBody(payload). + SetResult(&ChatwootConversation{}). // Expecting direct conversation object as response + Post(url) + + if err != nil { + log.Error().Err(err).Str("url", url).Interface("payload", payload).Msg("Chatwoot API: CreateConversation request failed") + return nil, fmt.Errorf("Chatwoot API CreateConversation request failed: %w", err) + } + + if resp.IsError() { + log.Error().Str("url", url).Interface("payload", payload).Int("statusCode", resp.StatusCode()).Str("responseBody", string(resp.Body())).Msg("Chatwoot API: CreateConversation returned an error") + return nil, fmt.Errorf("Chatwoot API CreateConversation error: status %s, body: %s", resp.Status(), resp.String()) + } + + conversation := resp.Result().(*ChatwootConversation) + log.Info().Int("conversationID", conversation.ID).Int("contactID", payload.ContactID).Msg("Successfully created Chatwoot conversation") + return conversation, nil +} + +// GetConversationsForContact retrieves conversations for a given contact ID. +func (c *Client) GetConversationsForContact(contactID int) ([]ChatwootConversation, error) { + url := fmt.Sprintf("/api/v1/accounts/%s/contacts/%d/conversations", c.accountID, contactID) + + var responsePayload ChatwootContactConversationsResponse // Expects {"payload": [...]} + resp, err := c.httpClient.R(). + SetResult(&responsePayload). + Get(url) + + if err != nil { + log.Error().Err(err).Str("url", url).Int("contactID", contactID).Msg("Chatwoot API: GetConversationsForContact request failed") + return nil, fmt.Errorf("Chatwoot API GetConversationsForContact request failed: %w", err) + } + + if resp.IsError() { + log.Error().Str("url", url).Int("contactID", contactID).Int("statusCode", resp.StatusCode()).Str("responseBody", string(resp.Body())).Msg("Chatwoot API: GetConversationsForContact returned an error") + return nil, fmt.Errorf("Chatwoot API GetConversationsForContact error: status %s, body: %s", resp.Status(), resp.String()) + } + + log.Info().Int("contactID", contactID).Int("conversationCount", len(responsePayload.Payload)).Msg("Successfully retrieved conversations for contact") + return responsePayload.Payload, nil +} + +// CreateMessage sends a message to a Chatwoot conversation. +func (c *Client) CreateMessage(conversationID int, payload ChatwootMessagePayload) (*ChatwootMessage, error) { + url := fmt.Sprintf("/api/v1/accounts/%s/conversations/%d/messages", c.accountID, conversationID) + + resp, err := c.httpClient.R(). + SetBody(payload). + SetResult(&ChatwootMessage{}). // Expecting ChatwootMessage as response + Post(url) + + if err != nil { + log.Error().Err(err).Str("url", url).Interface("payload", payload).Msg("Chatwoot API: CreateMessage request failed") + return nil, fmt.Errorf("Chatwoot API CreateMessage request failed: %w", err) + } + + if resp.IsError() { + // Log the full body for more context on API errors + log.Error().Str("url", url).Interface("payload", payload).Int("statusCode", resp.StatusCode()).Str("responseBody", string(resp.Body())).Msg("Chatwoot API: CreateMessage returned an error") + return nil, fmt.Errorf("Chatwoot API CreateMessage error: status %s, body: %s", resp.Status(), resp.String()) + } + + message := resp.Result().(*ChatwootMessage) + log.Info().Int("messageID", message.ID).Int("conversationID", conversationID).Msg("Successfully created Chatwoot message") + return message, nil +} + +// UploadFile uploads a file to Chatwoot's generic upload endpoint. +// Chatwoot typically expects attachments to be uploaded first, and then their IDs are passed when creating a message. +// The exact endpoint for general file uploads might be /api/v1/accounts/{account_id}/upload +// The response should contain an ID for the uploaded attachment. +func (c *Client) UploadFile(fileData []byte, fileName string, contentType string) (*ChatwootAttachment, error) { + // Note: The 'contentType' parameter might not be explicitly needed by SetFileBytes, + // as Resty might infer it or Chatwoot might determine it server-side. + // However, it's good practice to have it if the server requires a specific form field for it. + + // Using a common endpoint pattern, adjust if Chatwoot's specific endpoint is different. + // The direct upload endpoint might not be tied to a conversation yet. + url := fmt.Sprintf("/api/v1/accounts/%s/upload", c.accountID) + + // Chatwoot expects the file as 'attachment' or 'attachments[]' in multipart form. + // Let's assume 'attachment' for a single file upload. + resp, err := c.httpClient.R(). + SetFileBytes("attachment", fileName, fileData). // "attachment" is the form field name, fileName is the reported filename + // SetHeader("Content-Type", "multipart/form-data"). // Resty usually sets this automatically for SetFile/SetFileReader/SetFileBytes + SetResult(&ChatwootAttachment{}). // Expecting ChatwootAttachment as response + Post(url) + + if err != nil { + log.Error().Err(err).Str("url", url).Str("fileName", fileName).Msg("Chatwoot API: UploadFile request failed") + return nil, fmt.Errorf("Chatwoot API UploadFile request failed for %s: %w", fileName, err) + } + + if resp.IsError() { + log.Error().Str("url", url).Str("fileName", fileName).Int("statusCode", resp.StatusCode()).Str("responseBody", string(resp.Body())).Msg("Chatwoot API: UploadFile returned an error") + return nil, fmt.Errorf("Chatwoot API UploadFile error for %s: status %s, body: %s", fileName, resp.Status(), resp.String()) + } + + attachment := resp.Result().(*ChatwootAttachment) + if attachment.ID == 0 { + log.Error().Str("fileName", fileName).Interface("response", attachment).Msg("Chatwoot API: UploadFile response did not contain a valid attachment ID") + return nil, fmt.Errorf("Chatwoot API UploadFile for %s returned no ID", fileName) + } + + log.Info().Int("attachmentID", attachment.ID).Str("fileName", fileName).Str("dataURL", attachment.DataURL).Msg("Successfully uploaded file to Chatwoot") + return attachment, nil +} diff --git a/wuzapi-chatwoot-integration/internal/adapters/chatwoot/types.go b/wuzapi-chatwoot-integration/internal/adapters/chatwoot/types.go new file mode 100644 index 00000000..3ba3dbdf --- /dev/null +++ b/wuzapi-chatwoot-integration/internal/adapters/chatwoot/types.go @@ -0,0 +1,137 @@ +package chatwoot + +// ChatwootContactPayload is used to create a contact in Chatwoot. +type ChatwootContactPayload struct { + InboxID int `json:"inbox_id"` // Changed to int as per requirement + Name string `json:"name,omitempty"` + PhoneNumber string `json:"phone_number,omitempty"` + Email string `json:"email,omitempty"` + // CustomAttributes map[string]string `json:"custom_attributes,omitempty"` // Example +} + +// ChatwootContact represents a contact in Chatwoot. Renamed from ChatwootContactResponse for clarity. +type ChatwootContact struct { + ID int `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + PhoneNumber string `json:"phone_number"` + AvatarURL string `json:"avatar_url"` + Type string `json:"type"` // "contact" + PubsubToken string `json:"pubsub_token"` + CustomAttributes map[string]interface{} `json:"custom_attributes"` + // Add other fields as necessary, e.g., from Chatwoot's API documentation + // "additional_attributes", "source_id", "created_at", "updated_at" +} + +// ChatwootContactSearchPayload is used when searching for contacts. +// Chatwoot API typically returns a list under a "payload" key. +type ChatwootContactSearchPayload struct { + Payload []ChatwootContact `json:"payload"` +} + +// ChatwootCreateContactResponse is the direct response when creating a contact. +// It often includes a "payload" which contains the contact itself, or just the contact fields directly. +// Assuming it returns the contact directly for simplicity, matching ChatwootContact. +// If it's nested under "payload", then this would be: +// type ChatwootCreateContactResponse struct { +// Payload ChatwootContact `json:"payload"` +// } +// For now, let's assume the CreateContact method in the client will parse into ChatwootContact directly. + + +// ChatwootConversationPayload is used to create a conversation. +type ChatwootConversationPayload struct { + SourceID string `json:"source_id,omitempty"` // Wuzapi Sender ID (phone number) or other external ID + InboxID int `json:"inbox_id"` // Required: ID of the inbox (must be int) + ContactID int `json:"contact_id"` // Required: ID of the existing contact + Status string `json:"status,omitempty"` // e.g., "open", "pending"; defaults to "open" if not provided + AssigneeID int `json:"assignee_id,omitempty"` + // AdditionalAttributes map[string]interface{} `json:"additional_attributes,omitempty"` // For custom attributes on conversation +} + +// ChatwootConversation represents a conversation in Chatwoot. +// Renamed from ChatwootConversationResponse for consistency. +type ChatwootConversation struct { + ID int `json:"id"` + ContactID int `json:"contact_id"` // This is usually part of the contact object within the conversation payload from API + InboxID int `json:"inbox_id"` + Status string `json:"status"` + AccountID int `json:"account_id"` + AgentLastSeenAt int64 `json:"agent_last_seen_at"` // Unix timestamp + ContactLastSeenAt int64 `json:"contact_last_seen_at"` // Unix timestamp + Timestamp int64 `json:"timestamp"` // Unix timestamp of the last activity + // Meta ChatwootConversationMeta `json:"meta"` // Contains sender, assignee etc. + // Add other relevant fields like messages array, labels, etc. +} + +// ChatwootContactConversationsResponse is used when listing conversations for a contact. +// Chatwoot API returns a list under a "payload" key. +type ChatwootContactConversationsResponse struct { + Payload []ChatwootConversation `json:"payload"` +} + + +// ChatwootMessagePayload is used to create a message in a Chatwoot conversation. +type ChatwootMessagePayload struct { + Content string `json:"content,omitempty"` // Caption for media, or text message content + MessageType string `json:"message_type"` + ContentType string `json:"content_type"` // "text", or "input_file" when sending attachments + Private bool `json:"private"` + SourceID string `json:"source_id,omitempty"` + Attachments []ChatwootAttachmentToken `json:"attachment_ids,omitempty"` // Use this to send IDs of pre-uploaded attachments +} + +// ChatwootAttachmentToken is a helper type for passing attachment IDs when creating a message. +type ChatwootAttachmentToken struct { + ID int `json:"id"` +} + +// ChatwootMessage represents a message object in Chatwoot, often part of a response. +// Renamed from ChatwootCreateMessageResponse for clarity and consistency. +type ChatwootMessage struct { + ID int `json:"id"` + Content string `json:"content"` + AccountID int `json:"account_id"` + InboxID int `json:"inbox_id"` + ConversationID int `json:"conversation_id"` + MessageType int `json:"message_type"` // Note: Chatwoot API uses integer for message_type (0 for incoming, 1 for outgoing, 2 for template) + ContentType string `json:"content_type"` // e.g., "text", "incoming_email" + Private bool `json:"private"` + CreatedAt int64 `json:"created_at"` // Unix timestamp + SourceID *string `json:"source_id"` // Pointer to allow null + Sender *ChatwootMessageSender `json:"sender,omitempty"` // Details about the sender (contact or agent) + Attachments []ChatwootAttachment `json:"attachments,omitempty"` // Details of attachments on a received message +} + +// ChatwootMessageSender represents the sender of a message in Chatwoot. +type ChatwootMessageSender struct { + ID int `json:"id"` + Name string `json:"name"` + AvatarURL string `json:"avatar_url"` + Type string `json:"type"` // "contact", "agent_bot", "user" +} + + +// ChatwootAttachment represents an attachment object in Chatwoot, often part of a message response or upload response. +// Renamed from ChatwootAttachmentResponse for clarity. +type ChatwootAttachment struct { + ID int `json:"id"` + FileType string `json:"file_type"` // e.g., "image", "audio", "video", "file", "location" (for location type messages) + DataURL string `json:"data_url"` // Public URL of the attachment, if available + FileURL string `json:"file_url"` // Internal URL of the attachment + ThumbURL string `json:"thumb_url,omitempty"` // Thumbnail URL for images/videos + FileSize int `json:"file_size,omitempty"` + FileName string `json:"file_name,omitempty"` // If provided during upload or derived +} + + +// ChatwootWebhookPayload represents the data received from a Chatwoot webhook. +// This will vary greatly depending on the event type. This is a generic structure. +type ChatwootWebhookPayload struct { + Event string `json:"event"` // e.g., "message_created", "conversation_status_changed" + Conversation *ChatwootConversation `json:"conversation,omitempty"` + Message *ChatwootMessage `json:"message,omitempty"` // Changed to ChatwootMessage + Contact *ChatwootContact `json:"contact,omitempty"` + AccountID int `json:"account_id"` + // Add other fields specific to different events +} diff --git a/wuzapi-chatwoot-integration/internal/db/database.go b/wuzapi-chatwoot-integration/internal/db/database.go new file mode 100644 index 00000000..1cb54712 --- /dev/null +++ b/wuzapi-chatwoot-integration/internal/db/database.go @@ -0,0 +1,82 @@ +package db + +import ( + "fmt" + // "log" // Standard log no longer needed for GORM logger + stlog "log" // Alias for standard log if still needed for GORM's logger.New + + "github.com/rs/zerolog/log" // Use zerolog's global logger + "gorm.io/driver/sqlite" + "gorm.io/gorm" + gormlogger "gorm.io/gorm/logger" +) + +// DB is the global database connection instance. +var DB *gorm.DB + +// InitDB initializes the database connection using the provided DSN. +func InitDB(dsn string) error { + if dsn == "" { + return fmt.Errorf("database DSN cannot be empty") + } + + // Configure GORM logger to use zerolog + // GORM's logger.New expects a standard log.Logger instance. + // We can create one that writes to zerolog, or use a simpler GORM logger config. + // For simplicity, let's use GORM's default logger but adjust its level based on zerolog's level. + var gormLogLevel gormlogger.LogLevel + zerologLevel := log.Logger.GetLevel() // Get current global zerolog level + switch zerologLevel { + case gormlogger.Silent: + gormLogLevel = gormlogger.Silent + case gormlogger.Error: + gormLogLevel = gormlogger.Error + case gormlogger.Warn: + gormLogLevel = gormlogger.Warn + default: // Includes Info, Debug, Trace + gormLogLevel = gormlogger.Info + } + + newLogger := gormlogger.New( + stlog.New(log.Logger, "", stlog.LstdFlags), // Use zerolog's global logger as the writer for GORM + gormlogger.Config{ + SlowThreshold: gormlogger.DefaultSlowThreshold, // Or configure as needed + LogLevel: gormLogLevel, + IgnoreRecordNotFoundError: true, // Or false based on preference + Colorful: false, // Zerolog will handle coloring if its output is console + }, + ) + + var err error + DB, err = gorm.Open(sqlite.Open(dsn), &gorm.Config{ + Logger: newLogger, + }) + if err != nil { + return fmt.Errorf("failed to connect to database: %w", err) + } + + log.Info().Msg("Database connection established successfully.") + return nil +} + +// MigrateDB runs GORM's AutoMigrate for the defined models. +// It should be called after InitDB. +// The actual model types will be passed from main.go or another setup function +// to avoid direct dependency from db to models if models also need db. +func MigrateDB(modelsToMigrate ...interface{}) error { + if DB == nil { + return fmt.Errorf("database not initialized, call InitDB first") + } + + if len(modelsToMigrate) == 0 { + return fmt.Errorf("no models provided for migration") + } + + err := DB.AutoMigrate(modelsToMigrate...) + if err != nil { + return fmt.Errorf("failed to auto-migrate database: %w", err) + } + + log.Info().Int("models_migrated", len(modelsToMigrate)).Msg("Database migration completed successfully for provided models.") + return nil +} diff --git a/wuzapi-chatwoot-integration/internal/domain/contact.go b/wuzapi-chatwoot-integration/internal/domain/contact.go new file mode 100644 index 00000000..88e8bd50 --- /dev/null +++ b/wuzapi-chatwoot-integration/internal/domain/contact.go @@ -0,0 +1,2 @@ +package domain +// Contact related domain logic diff --git a/wuzapi-chatwoot-integration/internal/domain/conversation.go b/wuzapi-chatwoot-integration/internal/domain/conversation.go new file mode 100644 index 00000000..65058991 --- /dev/null +++ b/wuzapi-chatwoot-integration/internal/domain/conversation.go @@ -0,0 +1,2 @@ +package domain +// Conversation related domain logic diff --git a/wuzapi-chatwoot-integration/internal/domain/message.go b/wuzapi-chatwoot-integration/internal/domain/message.go new file mode 100644 index 00000000..895fb492 --- /dev/null +++ b/wuzapi-chatwoot-integration/internal/domain/message.go @@ -0,0 +1,2 @@ +package domain +// Message related domain logic diff --git a/wuzapi-chatwoot-integration/internal/handlers/chatwoot_webhook.go b/wuzapi-chatwoot-integration/internal/handlers/chatwoot_webhook.go new file mode 100644 index 00000000..4816c1d3 --- /dev/null +++ b/wuzapi-chatwoot-integration/internal/handlers/chatwoot_webhook.go @@ -0,0 +1,2 @@ +package handlers +// Chatwoot webhook handler diff --git a/wuzapi-chatwoot-integration/internal/handlers/wuzapi_webhook.go b/wuzapi-chatwoot-integration/internal/handlers/wuzapi_webhook.go new file mode 100644 index 00000000..07f7dcd7 --- /dev/null +++ b/wuzapi-chatwoot-integration/internal/handlers/wuzapi_webhook.go @@ -0,0 +1,216 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "strings" + // "wuzapi-chatwoot-integration/config" // No longer needed for direct access + "wuzapi-chatwoot-integration/internal/adapters/wuzapi" // Import for WuzapiEventPayload + "wuzapi-chatwoot-integration/internal/services" // Import for ContactSyncService + + "github.com/rs/zerolog/log" +) + +// WuzapiHandler is a struct that holds dependencies for Wuzapi webhook processing. +type WuzapiHandler struct { + contactService *services.ContactSyncService + conversationService *services.ConversationSyncService + messageService *services.MessageSyncService + webhookSecret string +} + +// NewWuzapiHandler creates a new WuzapiHandler with necessary dependencies. +func NewWuzapiHandler( + contactService *services.ContactSyncService, + conversationService *services.ConversationSyncService, + messageService *services.MessageSyncService, + secret string, +) *WuzapiHandler { + if contactService == nil { + log.Fatal().Msg("ContactSyncService cannot be nil for WuzapiHandler") + } + if conversationService == nil { + log.Fatal().Msg("ConversationSyncService cannot be nil for WuzapiHandler") + } + if messageService == nil { + log.Fatal().Msg("MessageSyncService cannot be nil for WuzapiHandler") + } + return &WuzapiHandler{ + contactService: contactService, + conversationService: conversationService, + messageService: messageService, + webhookSecret: secret, + } +} + +// isValidSignature (Placeholder - can be a method of WuzapiHandler or a standalone utility) +// For now, keeping it similar to before but using h.webhookSecret. +func (h *WuzapiHandler) isValidSignature(body []byte, signature string) bool { + if h.webhookSecret == "" { + log.Warn().Msg("Webhook secret is not configured in WuzapiHandler. Skipping signature validation.") + return true // Or false, depending on desired behavior + } + if signature == "" { + log.Warn().Msg("No signature provided in X-Wuzapi-Signature header.") + return false + } + // TODO: Implement actual HMAC SHA256 validation. + if h.webhookSecret == "dev-secret" || signature == "dev-signature" { + log.Warn().Msg("Using DEV signature validation. NOT FOR PRODUCTION.") + return true + } + log.Warn().Str("signature", signature).Msg("Signature validation failed (placeholder logic).") + return false +} + +// Handle processes incoming webhooks from Wuzapi. +func (h *WuzapiHandler) Handle(w http.ResponseWriter, r *http.Request) { + // 1. Signature Validation + signature := r.Header.Get("X-Wuzapi-Signature") + + bodyBytes, err := io.ReadAll(r.Body) + if err != nil { + log.Error().Err(err).Msg("Failed to read request body") + http.Error(w, "Failed to read request body", http.StatusInternalServerError) + return + } + r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) // Restore body + + if !h.isValidSignature(bodyBytes, signature) { + log.Warn().Msg("Invalid webhook signature") + http.Error(w, "Invalid signature", http.StatusUnauthorized) + return + } + + // 2. Request Body Parsing + var eventPayload wuzapi.WuzapiEventPayload + if err := json.NewDecoder(r.Body).Decode(&eventPayload); err != nil { + log.Error().Err(err).Msg("Failed to decode JSON request body into WuzapiEventPayload") + http.Error(w, "Invalid JSON payload", http.StatusBadRequest) + return + } + + // 3. Logging + // Determine the primary event type string + primaryEventType := eventPayload.Event + if primaryEventType == "" { + primaryEventType = eventPayload.Type + } + + log.Info().Str("eventType", primaryEventType).Str("instanceID", eventPayload.InstanceID).Msg("Received Wuzapi event") + log.Debug().Interface("payload", eventPayload).Msg("Wuzapi event payload") + + // 4. Basic Event Processing (Dispatch Placeholder) + if strings.TrimSpace(primaryEventType) == "" { + log.Warn().Interface("payload", eventPayload).Msg("Event type is empty or not found in Wuzapi payload.") + w.WriteHeader(http.StatusOK) + return + } + + switch primaryEventType { + case "message.received", "message:received", "message_received": + log.Info().Str("eventType", primaryEventType).Msg("Processing Wuzapi incoming message event") + + if eventPayload.Message == nil { + log.Error().Interface("payload", eventPayload).Msg("Wuzapi message.received event has no 'message' data") + // Acknowledge to prevent retries, but this is an unexpected payload structure + w.WriteHeader(http.StatusOK) + return + } + + senderPhone := eventPayload.Message.From + senderName := eventPayload.Message.SenderName + if senderName == "" { // Fallback if SenderName is not provided, try PushName + senderName = eventPayload.Message.PushName + } + + if senderPhone == "" { + log.Error().Interface("messagePayload", eventPayload.Message).Msg("Failed to extract sender phone number from Wuzapi message.received event") + // Depending on requirements, might send http.StatusBadRequest or just acknowledge + } else { + contact, err := h.contactService.FindOrCreateContactFromWuzapi(senderPhone, senderName) + if err != nil { + log.Error().Err(err).Str("senderPhone", senderPhone).Msg("Error finding or creating contact from Wuzapi event") + // Acknowledge to prevent Wuzapi retries for this specific error path + w.WriteHeader(http.StatusOK) + return + } + + log.Info().Int("chatwootContactID", contact.ID).Str("senderPhone", senderPhone).Msg("Successfully found/created Chatwoot contact for Wuzapi message") + + // Now, find or create the conversation + conversationMap, err := h.conversationService.FindOrCreateConversation(senderPhone, contact) + if err != nil { + log.Error().Err(err).Str("senderPhone", senderPhone).Int("chatwootContactID", contact.ID).Msg("Error finding or creating conversation") + // Acknowledge to prevent Wuzapi retries + w.WriteHeader(http.StatusOK) + return + } + log.Info(). + Uint("chatwootConversationID", conversationMap.ChatwootConversationID). + Str("senderPhone", senderPhone). + Msg("Successfully ensured conversation exists and is mapped") + + // Sync the message content + wuzapiMsgData := eventPayload.Message + isText := wuzapiMsgData.Type == "text" || wuzapiMsgData.Type == "chat" || (wuzapiMsgData.Text != "" && wuzapiMsgData.MediaURL == "") + isMedia := wuzapiMsgData.MediaURL != "" && (wuzapiMsgData.Type == "image" || wuzapiMsgData.Type == "video" || wuzapiMsgData.Type == "audio" || wuzapiMsgData.Type == "document" || wuzapiMsgData.Type == "sticker") + + if isText { + err = h.messageService.SyncWuzapiTextMessageToChatwoot(conversationMap, wuzapiMsgData) + if err != nil { + log.Error().Err(err). + Str("wuzapiMessageID", wuzapiMsgData.ID). + Uint("chatwootConversationID", conversationMap.ChatwootConversationID). + Msg("Error syncing Wuzapi text message to Chatwoot") + // Error already logged by service, acknowledge to Wuzapi + } else { + log.Info(). + Str("wuzapiMessageID", wuzapiMsgData.ID). + Uint("chatwootConversationID", conversationMap.ChatwootConversationID). + Msg("Successfully initiated sync of Wuzapi text message to Chatwoot") + } + } else if isMedia { + err = h.messageService.SyncWuzapiMediaMessageToChatwoot(conversationMap, wuzapiMsgData) + if err != nil { + log.Error().Err(err). + Str("wuzapiMessageID", wuzapiMsgData.ID). + Str("mediaURL", wuzapiMsgData.MediaURL). + Uint("chatwootConversationID", conversationMap.ChatwootConversationID). + Msg("Error syncing Wuzapi media message to Chatwoot") + // Error already logged by service, acknowledge to Wuzapi + } else { + log.Info(). + Str("wuzapiMessageID", wuzapiMsgData.ID). + Uint("chatwootConversationID", conversationMap.ChatwootConversationID). + Msg("Successfully initiated sync of Wuzapi media message to Chatwoot") + } + } else { + log.Info(). + Str("wuzapiMessageID", wuzapiMsgData.ID). + Str("wuzapiMessageType", wuzapiMsgData.Type). + Msg("Wuzapi message is not a simple text or known media type, skipping sync.") + } + } + + case "message.sent", "message:sent", "message_sent": + log.Info().Str("eventType", primaryEventType).Msg("Processing Wuzapi message sent event") + // Placeholder: Call statusUpdateService.HandleWuzapiMessageSent(eventPayload) + case "message.delivered", "message:delivered", "message_delivered": + log.Info().Str("eventType", primaryEventType).Msg("Processing Wuzapi message delivered event") + // Placeholder: Call statusUpdateService.HandleWuzapiMessageDelivered(eventPayload) + case "message.read", "message:read", "message_read": + log.Info().Str("eventType", primaryEventType).Msg("Processing Wuzapi message read event") + case "instance.status", "instance:status", "instance_status": + log.Info().Str("eventType", primaryEventType).Msg("Processing Wuzapi instance status event") + // Placeholder: Call instanceStateService.HandleWuzapiStatusUpdate(eventPayload) + default: + log.Warn().Str("eventType", primaryEventType).Msg("Received unknown Wuzapi event type") + } + + // 5. Respond to Wuzapi + w.WriteHeader(http.StatusOK) // Acknowledge receipt + // _, _ = w.Write([]byte("Acknowledged")) // Optional response body +} diff --git a/wuzapi-chatwoot-integration/internal/models/models.go b/wuzapi-chatwoot-integration/internal/models/models.go new file mode 100644 index 00000000..8ff0ffb9 --- /dev/null +++ b/wuzapi-chatwoot-integration/internal/models/models.go @@ -0,0 +1,33 @@ +package models + +import ( + "time" +) + +// ConversationMap maps a Wuzapi sender ID (e.g., phone number) to Chatwoot contact and conversation IDs. +// This helps in quickly finding existing Chatwoot conversations for incoming Wuzapi messages. +type ConversationMap struct { + ID uint `gorm:"primaryKey"` + WuzapiSenderID string `gorm:"uniqueIndex;comment:Identifier for the sender from Wuzapi, e.g., phone number"` + ChatwootContactID uint `gorm:"comment:ID of the contact in Chatwoot"` + ChatwootConversationID uint `gorm:"uniqueIndex;comment:ID of the conversation in Chatwoot"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` +} + +// QueuedMessage represents a message that needs to be sent to either Wuzapi or Chatwoot. +// It's used for reliable message delivery, allowing for retries. +type QueuedMessage struct { + ID uint `gorm:"primaryKey"` + WuzapiMessageID string `gorm:"index;comment:ID from Wuzapi, if message originated from/sent to Wuzapi"` + ChatwootMessageID uint `gorm:"index;comment:ID from Chatwoot, if message originated from/sent to Chatwoot"` + Direction string `gorm:"comment:Direction of sync, e.g., 'wuzapi-to-chatwoot' or 'chatwoot-to-wuzapi'"` + Payload string `gorm:"type:text;comment:JSON payload of the message to be sent/retried"` + RetryCount int `gorm:"default:0;comment:Number of times delivery has been attempted"` + LastError string `gorm:"type:text;comment:Last error message encountered during delivery attempt"` + Status string `gorm:"index;comment:Current status, e.g., pending, failed, success, processing"` + Source string `gorm:"comment:The system that originated the event, e.g., wuzapi, chatwoot"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + NextRetryAt time.Time `gorm:"index;comment:Scheduled time for the next retry attempt"` +} diff --git a/wuzapi-chatwoot-integration/internal/services/contact_sync.go b/wuzapi-chatwoot-integration/internal/services/contact_sync.go new file mode 100644 index 00000000..7e516d17 --- /dev/null +++ b/wuzapi-chatwoot-integration/internal/services/contact_sync.go @@ -0,0 +1,81 @@ +package services + +import ( + "fmt" + "strconv" + "wuzapi-chatwoot-integration/internal/adapters/chatwoot" + + "github.com/rs/zerolog/log" +) + +// ContactSyncService handles the logic for synchronizing contacts between Wuzapi and Chatwoot. +type ContactSyncService struct { + chatwootClient *chatwoot.Client + chatwootInboxID int +} + +// NewContactSyncService creates a new ContactSyncService. +func NewContactSyncService(cwClient *chatwoot.Client, inboxIDStr string) (*ContactSyncService, error) { + if cwClient == nil { + return nil, fmt.Errorf("Chatwoot client cannot be nil") + } + if inboxIDStr == "" { + return nil, fmt.Errorf("Chatwoot inbox ID string cannot be empty") + } + + inboxID, err := strconv.Atoi(inboxIDStr) + if err != nil { + log.Error().Err(err).Str("inboxIDStr", inboxIDStr).Msg("Failed to convert Chatwoot Inbox ID string to int") + return nil, fmt.Errorf("failed to convert Chatwoot Inbox ID '%s' to int: %w", inboxIDStr, err) + } + + return &ContactSyncService{ + chatwootClient: cwClient, + chatwootInboxID: inboxID, + }, nil +} + +// FindOrCreateContactFromWuzapi attempts to find an existing Chatwoot contact by the Wuzapi sender's phone number. +// If not found, it creates a new contact in Chatwoot. +func (s *ContactSyncService) FindOrCreateContactFromWuzapi(wuzapiSenderPhone, wuzapiSenderName string) (*chatwoot.ChatwootContact, error) { + log.Info().Str("phoneNumber", wuzapiSenderPhone).Str("name", wuzapiSenderName).Msg("Attempting to find or create Chatwoot contact") + + // Normalize phone number if necessary (e.g., ensure E.164 format) + // For now, assume wuzapiSenderPhone is in a consistent format Chatwoot expects. + + contact, err := s.chatwootClient.GetContactByPhone(wuzapiSenderPhone) + if err != nil { + // Don't treat "not found" from GetContactByPhone as a fatal error for this service's logic, + // as we intend to create the contact if it's not found. + // The client's GetContactByPhone already logs detailed errors. + log.Warn().Err(err).Str("phoneNumber", wuzapiSenderPhone).Msg("Error trying to get contact by phone, will attempt to create.") + // Proceed to create, err from GetContactByPhone might indicate a problem beyond just "not found" + } + + if contact != nil { + log.Info().Int("contactID", contact.ID).Str("phoneNumber", wuzapiSenderPhone).Msg("Found existing Chatwoot contact") + return contact, nil + } + + // Contact not found, or an error occurred that didn't prevent creation attempt. Let's create one. + log.Info().Str("phoneNumber", wuzapiSenderPhone).Str("name", wuzapiSenderName).Msg("Chatwoot contact not found, creating a new one.") + + payload := chatwoot.ChatwootContactPayload{ + InboxID: s.chatwootInboxID, + PhoneNumber: wuzapiSenderPhone, + Name: wuzapiSenderName, // Chatwoot will use phone number as name if name is empty + } + + if wuzapiSenderName == "" { + log.Info().Str("phoneNumber", wuzapiSenderPhone).Msg("Wuzapi sender name is empty, Chatwoot will likely use phone number as name.") + } + + newContact, createErr := s.chatwootClient.CreateContact(payload) + if createErr != nil { + log.Error().Err(createErr).Str("phoneNumber", wuzapiSenderPhone).Msg("Failed to create Chatwoot contact") + return nil, fmt.Errorf("failed to create Chatwoot contact for %s: %w", wuzapiSenderPhone, createErr) + } + + log.Info().Int("contactID", newContact.ID).Str("phoneNumber", newContact.PhoneNumber).Msg("Successfully created new Chatwoot contact") + return newContact, nil +} diff --git a/wuzapi-chatwoot-integration/internal/services/conversation_sync.go b/wuzapi-chatwoot-integration/internal/services/conversation_sync.go new file mode 100644 index 00000000..fbe286d5 --- /dev/null +++ b/wuzapi-chatwoot-integration/internal/services/conversation_sync.go @@ -0,0 +1,138 @@ +package services + +import ( + "errors" + "fmt" + "strconv" + "wuzapi-chatwoot-integration/internal/adapters/chatwoot" + "wuzapi-chatwoot-integration/internal/models" + + "github.com/rs/zerolog/log" + "gorm.io/gorm" +) + +// ConversationSyncService handles finding or creating Chatwoot conversations and mapping them. +type ConversationSyncService struct { + chatwootClient *chatwoot.Client + db *gorm.DB + chatwootInboxID int +} + +// NewConversationSyncService creates a new ConversationSyncService. +func NewConversationSyncService(cwClient *chatwoot.Client, db *gorm.DB, inboxIDStr string) (*ConversationSyncService, error) { + if cwClient == nil { + return nil, fmt.Errorf("Chatwoot client cannot be nil") + } + if db == nil { + return nil, fmt.Errorf("database instance (gorm.DB) cannot be nil") + } + if inboxIDStr == "" { + return nil, fmt.Errorf("Chatwoot inbox ID string cannot be empty") + } + + inboxID, err := strconv.Atoi(inboxIDStr) + if err != nil { + log.Error().Err(err).Str("inboxIDStr", inboxIDStr).Msg("Failed to convert Chatwoot Inbox ID string to int for ConversationSyncService") + return nil, fmt.Errorf("failed to convert Chatwoot Inbox ID '%s' to int: %w", inboxIDStr, err) + } + + return &ConversationSyncService{ + chatwootClient: cwClient, + db: db, + chatwootInboxID: inboxID, + }, nil +} + +// FindOrCreateConversation finds an existing Chatwoot conversation for a Wuzapi sender +// or creates a new one if none suitable is found. It also maintains a local mapping in the DB. +func (s *ConversationSyncService) FindOrCreateConversation(wuzapiSenderID string, chatwootContact *chatwoot.ChatwootContact) (*models.ConversationMap, error) { + log.Info().Str("wuzapiSenderID", wuzapiSenderID).Int("chatwootContactID", chatwootContact.ID).Msg("Finding or creating Chatwoot conversation") + + // 1. Check DB Cache First + var conversationMap models.ConversationMap + err := s.db.Where("wuzapi_sender_id = ?", wuzapiSenderID).First(&conversationMap).Error + if err == nil { + // Found in DB + log.Info(). + Str("wuzapiSenderID", wuzapiSenderID). + Uint("chatwootConversationID", conversationMap.ChatwootConversationID). + Msg("Conversation map found in DB cache") + return &conversationMap, nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + log.Error().Err(err).Str("wuzapiSenderID", wuzapiSenderID).Msg("Error querying ConversationMap from DB") + return nil, fmt.Errorf("error querying ConversationMap: %w", err) + } + // Record not found, proceed to check Chatwoot + + // 2. Check Chatwoot for Existing Conversations for this contact + log.Info().Int("chatwootContactID", chatwootContact.ID).Msg("Checking Chatwoot for existing conversations for contact") + conversations, err := s.chatwootClient.GetConversationsForContact(chatwootContact.ID) + if err != nil { + log.Error().Err(err).Int("chatwootContactID", chatwootContact.ID).Msg("Failed to get conversations for contact from Chatwoot") + // Depending on the error, we might still try to create a new one or return. + // If it's a transient error, retrying might be an option. For now, proceed to create. + } + + if err == nil { // Only proceed if GetConversationsForContact didn't error out + for _, conv := range conversations { + // We need a way to identify if this conversation is the "right" one. + // For Wuzapi, a contact usually has one main conversation per inbox. + // If SourceID was set on conversation creation, we could check that. + // For now, let's assume any existing open conversation in the target inbox is usable. + // A more robust check might involve looking for conversations with a specific source_id + // or specific custom attributes if Wuzapi integration sets them. + if conv.InboxID == s.chatwootInboxID && (conv.Status == "open" || conv.Status == "pending") { + log.Info(). + Int("chatwootConversationID", conv.ID). + Int("chatwootContactID", chatwootContact.ID). + Str("wuzapiSenderID", wuzapiSenderID). + Msg("Found suitable existing Chatwoot conversation for contact in the correct inbox") + + return s.storeConversationMap(wuzapiSenderID, chatwootContact.ID, uint(conv.ID)) + } + } + } + + + // 3. Create New Conversation in Chatwoot + log.Info().Str("wuzapiSenderID", wuzapiSenderID).Int("chatwootContactID", chatwootContact.ID).Msg("No suitable existing conversation found, creating new one in Chatwoot") + payload := chatwoot.ChatwootConversationPayload{ + SourceID: wuzapiSenderID, // Use Wuzapi sender ID as source_id for traceability + InboxID: s.chatwootInboxID, + ContactID: chatwootContact.ID, + Status: "open", // Default to open status + } + + newConv, err := s.chatwootClient.CreateConversation(payload) + if err != nil { + log.Error().Err(err).Str("wuzapiSenderID", wuzapiSenderID).Msg("Failed to create new conversation in Chatwoot") + return nil, fmt.Errorf("failed to create Chatwoot conversation: %w", err) + } + + log.Info(). + Int("newChatwootConversationID", newConv.ID). + Str("wuzapiSenderID", wuzapiSenderID). + Msg("Successfully created new Chatwoot conversation") + + return s.storeConversationMap(wuzapiSenderID, chatwootContact.ID, uint(newConv.ID)) +} + +// storeConversationMap saves the mapping to the database. +func (s *ConversationSyncService) storeConversationMap(wuzapiSenderID string, chatwootContactID int, chatwootConversationID uint) (*models.ConversationMap, error) { + cm := models.ConversationMap{ + WuzapiSenderID: wuzapiSenderID, + ChatwootContactID: uint(chatwootContactID), + ChatwootConversationID: chatwootConversationID, + } + if err := s.db.Create(&cm).Error; err != nil { + log.Error().Err(err). + Str("wuzapiSenderID", wuzapiSenderID). + Int("chatwootContactID", chatwootContactID). + Uint("chatwootConversationID", chatwootConversationID). + Msg("Failed to save ConversationMap to DB") + return nil, fmt.Errorf("failed to save ConversationMap: %w", err) + } + log.Info().Str("wuzapiSenderID", cm.WuzapiSenderID).Uint("chatwootConversationID", cm.ChatwootConversationID).Msg("Conversation map stored in DB") + return &cm, nil +} diff --git a/wuzapi-chatwoot-integration/internal/services/message_sync.go b/wuzapi-chatwoot-integration/internal/services/message_sync.go new file mode 100644 index 00000000..c9f87cd1 --- /dev/null +++ b/wuzapi-chatwoot-integration/internal/services/message_sync.go @@ -0,0 +1,194 @@ +package services + +import ( + "fmt" + "strings" + "wuzapi-chatwoot-integration/internal/adapters/chatwoot" + "wuzapi-chatwoot-integration/internal/adapters/wuzapi" // For wuzapi.WuzapiMessageData + "wuzapi-chatwoot-integration/internal/models" + + "github.com/rs/zerolog/log" + "gorm.io/gorm" +) + +// MessageSyncService handles sending messages to Chatwoot. +type MessageSyncService struct { + wuzapiClient *wuzapi.Client // Added Wuzapi client for downloading media + chatwootClient *chatwoot.Client + db *gorm.DB // For potential future use (e.g., queuing, message status updates) +} + +// NewMessageSyncService creates a new MessageSyncService. +func NewMessageSyncService(wzClient *wuzapi.Client, cwClient *chatwoot.Client, db *gorm.DB) (*MessageSyncService, error) { + if wzClient == nil { + return nil, fmt.Errorf("Wuzapi client cannot be nil for MessageSyncService") + } + if cwClient == nil { + return nil, fmt.Errorf("Chatwoot client cannot be nil for MessageSyncService") + } + if db == nil { + return nil, fmt.Errorf("database instance (gorm.DB) cannot be nil for MessageSyncService") + } + return &MessageSyncService{ + wuzapiClient: wzClient, + chatwootClient: cwClient, + db: db, + }, nil +} + +// SyncWuzapiTextMessageToChatwoot prepares and sends a text message from Wuzapi to Chatwoot. +func (s *MessageSyncService) SyncWuzapiTextMessageToChatwoot( + conversationMap *models.ConversationMap, + wuzapiMsgData *wuzapi.WuzapiMessageData, +) error { + if conversationMap == nil { + return fmt.Errorf("conversationMap cannot be nil") + } + if wuzapiMsgData == nil { + return fmt.Errorf("wuzapiMsgData cannot be nil") + } + + textContent := wuzapiMsgData.Text + if textContent == "" { // Fallback to Content field if Text (body) is empty + textContent = wuzapiMsgData.Content + } + if textContent == "" { + log.Warn().Str("wuzapiMessageID", wuzapiMsgData.ID).Msg("Wuzapi message has no text content to sync.") + return nil // Or an error if empty messages should not be synced or handled differently + } + + log.Info(). + Str("wuzapiMessageID", wuzapiMsgData.ID). + Uint("chatwootConversationID", conversationMap.ChatwootConversationID). + Msg("Attempting to sync Wuzapi text message to Chatwoot") + + payload := chatwoot.ChatwootMessagePayload{ + Content: textContent, + MessageType: "incoming", // Message from external source (Wuzapi) is "incoming" to Chatwoot + ContentType: "text", + Private: false, + SourceID: wuzapiMsgData.ID, // Store Wuzapi message ID for traceability + } + + createdMessage, err := s.chatwootClient.CreateMessage(int(conversationMap.ChatwootConversationID), payload) + if err != nil { + log.Error().Err(err). + Str("wuzapiMessageID", wuzapiMsgData.ID). + Uint("chatwootConversationID", conversationMap.ChatwootConversationID). + Msg("Failed to create message in Chatwoot") + return fmt.Errorf("failed to create message in Chatwoot for Wuzapi msg ID %s: %w", wuzapiMsgData.ID, err) + } + + log.Info(). + Str("wuzapiMessageID", wuzapiMsgData.ID). + Int("chatwootMessageID", createdMessage.ID). + Uint("chatwootConversationID", conversationMap.ChatwootConversationID). + Msg("Successfully synced Wuzapi text message to Chatwoot") + + // Future: Update QueuedMessage status if this was from a queue. + // Or, directly log message_id mapping if needed for other processes. + + return nil +} + +// SyncWuzapiMediaMessageToChatwoot handles downloading media from Wuzapi and uploading it to Chatwoot, +// then sends a message to Chatwoot with the attachment. +func (s *MessageSyncService) SyncWuzapiMediaMessageToChatwoot( + conversationMap *models.ConversationMap, + wuzapiMsgData *wuzapi.WuzapiMessageData, +) error { + if conversationMap == nil { + return fmt.Errorf("conversationMap cannot be nil for media message sync") + } + if wuzapiMsgData == nil { + return fmt.Errorf("wuzapiMsgData cannot be nil for media message sync") + } + if wuzapiMsgData.MediaURL == "" { + return fmt.Errorf("MediaURL is empty in wuzapiMsgData for Wuzapi msg ID %s", wuzapiMsgData.ID) + } + + log.Info(). + Str("wuzapiMessageID", wuzapiMsgData.ID). + Str("mediaURL", wuzapiMsgData.MediaURL). + Uint("chatwootConversationID", conversationMap.ChatwootConversationID). + Msg("Attempting to sync Wuzapi media message to Chatwoot") + + // 1. Download Media from Wuzapi + mediaData, contentType, err := s.wuzapiClient.DownloadMedia(wuzapiMsgData.MediaURL) + if err != nil { + log.Error().Err(err).Str("mediaURL", wuzapiMsgData.MediaURL).Msg("Failed to download media from Wuzapi") + return fmt.Errorf("failed to download Wuzapi media %s: %w", wuzapiMsgData.MediaURL, err) + } + log.Info().Str("mediaURL", wuzapiMsgData.MediaURL).Str("contentType", contentType).Int("size", len(mediaData)).Msg("Media downloaded from Wuzapi") + + // Determine filename + fileName := wuzapiMsgData.FileName + if fileName == "" { + // Try to derive from URL, or generate a generic one + urlParts := strings.Split(wuzapiMsgData.MediaURL, "/") + if len(urlParts) > 0 { + fileName = urlParts[len(urlParts)-1] + fileName = strings.Split(fileName, "?")[0] // Remove query params if any + } + if fileName == "" { + fileName = fmt.Sprintf("%s_attachment", wuzapiMsgData.ID) + if wuzapiMsgData.Mimetype != "" { // Try to add extension from mimetype + parts := strings.Split(wuzapiMsgData.Mimetype, "/") + if len(parts) == 2 { + fileName += "." + parts[1] + } + } + } + log.Info().Str("originalFileName", wuzapiMsgData.FileName).Str("derivedFileName", fileName).Msg("Filename was empty or cleaned, derived from URL/ID and mimetype") + } + + + // 2. Upload Media to Chatwoot + chatwootAttachment, err := s.chatwootClient.UploadFile(mediaData, fileName, contentType) + if err != nil { + log.Error().Err(err).Str("fileName", fileName).Msg("Failed to upload media to Chatwoot") + return fmt.Errorf("failed to upload media to Chatwoot (file: %s): %w", fileName, err) + } + log.Info().Int("chatwootAttachmentID", chatwootAttachment.ID).Str("fileName", fileName).Msg("Media uploaded to Chatwoot") + + // 3. Create Message in Chatwoot with the attachment + caption := wuzapiMsgData.Caption + if caption == "" { + caption = wuzapiMsgData.Text + if caption == "" { + caption = wuzapiMsgData.Content + } + } + if caption == "" && (wuzapiMsgData.Type == "voice" || wuzapiMsgData.Type == "audio") { + caption = "Audio message" // Default caption for voice/audio if none provided + } + + + messagePayload := chatwoot.ChatwootMessagePayload{ + Content: caption, + MessageType: "incoming", + ContentType: "input_file", + Private: false, + SourceID: wuzapiMsgData.ID, + Attachments: []chatwoot.ChatwootAttachmentToken{{ID: chatwootAttachment.ID}}, + } + + createdMessage, err := s.chatwootClient.CreateMessage(int(conversationMap.ChatwootConversationID), messagePayload) + if err != nil { + log.Error().Err(err). + Str("wuzapiMessageID", wuzapiMsgData.ID). + Int("chatwootAttachmentID", chatwootAttachment.ID). + Uint("chatwootConversationID", conversationMap.ChatwootConversationID). + Msg("Failed to create message with attachment in Chatwoot") + return fmt.Errorf("failed to create message with attachment in Chatwoot for Wuzapi msg ID %s: %w", wuzapiMsgData.ID, err) + } + + log.Info(). + Str("wuzapiMessageID", wuzapiMsgData.ID). + Int("chatwootMessageID", createdMessage.ID). + Int("chatwootAttachmentID", chatwootAttachment.ID). + Uint("chatwootConversationID", conversationMap.ChatwootConversationID). + Msg("Successfully synced Wuzapi media message to Chatwoot") + + return nil +} diff --git a/wuzapi-chatwoot-integration/main.go b/wuzapi-chatwoot-integration/main.go new file mode 100644 index 00000000..111e9a4f --- /dev/null +++ b/wuzapi-chatwoot-integration/main.go @@ -0,0 +1,109 @@ +package main + +import ( + "fmt" + "net/http" + "wuzapi-chatwoot-integration/config" + "wuzapi-chatwoot-integration/internal/adapters/chatwoot" + "wuzapi-chatwoot-integration/internal/adapters/wuzapi" + "wuzapi-chatwoot-integration/internal/db" + "wuzapi-chatwoot-integration/internal/handlers" // Import handlers package + "wuzapi-chatwoot-integration/internal/models" + "wuzapi-chatwoot-integration/internal/services" // Import services package + "wuzapi-chatwoot-integration/pkg/logger" // For InitLogger + "github.com/rs/zerolog/log" // Import zerolog's global logger +) + +func main() { + logger.InitLogger() // Configures the global log.Logger + + log.Info().Msg("Loading configuration...") + cfg, err := config.LoadConfig() + if err != nil { + log.Fatal().Err(err).Msg("Failed to load configuration") + } + log.Info().Msg("Configuration loaded successfully.") + // log.Debug().Interface("config", cfg).Msg("Loaded configuration values") // For debugging + + // Initialize Database + log.Info().Str("database_url", cfg.DatabaseURL).Msg("Initializing database...") // Log DSN safely + if err := db.InitDB(cfg.DatabaseURL); err != nil { + log.Fatal().Err(err).Msg("Failed to initialize database") + } + // db.InitDB now logs its own success message, so no need for: log.Info().Msg("Database initialized successfully.") + + // Run Migrations + log.Info().Msg("Running database migrations...") + if err := db.MigrateDB(&models.ConversationMap{}, &models.QueuedMessage{}); err != nil { + log.Fatal().Err(err).Msg("Failed to run database migrations") + } + // db.MigrateDB now logs its own success message, so no need for: log.Info().Msg("Database migrations completed successfully.") + + // Initialize Wuzapi Client + wClient, err := wuzapi.NewClient(cfg.WuzapiBaseURL, cfg.WuzapiAPIKey, cfg.WuzapiInstanceID) + if err != nil { + log.Fatal().Err(err).Msg("Failed to initialize Wuzapi client") + } else { + // Wuzapi NewClient is expected to log its own successful initialization message + // _ = wClient // No longer assign to blank if used by services + } + + // Initialize Chatwoot Client + cClient, err := chatwoot.NewClient(cfg.ChatwootBaseURL, cfg.ChatwootAccessToken, cfg.ChatwootAccountID, cfg.ChatwootInboxID) + if err != nil { + log.Fatal().Err(err).Msg("Failed to initialize Chatwoot client") + } else { + // Chatwoot NewClient is expected to log its own successful initialization message + // _ = cClient // No longer assigning to blank if used + } + + // Initialize Services + contactService, err := services.NewContactSyncService(cClient, cfg.ChatwootInboxID) + if err != nil { + log.Fatal().Err(err).Msg("Failed to initialize ContactSyncService") + } + log.Info().Msg("ContactSyncService initialized successfully") + + conversationService, err := services.NewConversationSyncService(cClient, db.DB, cfg.ChatwootInboxID) + if err != nil { + log.Fatal().Err(err).Msg("Failed to initialize ConversationSyncService") + } + log.Info().Msg("ConversationSyncService initialized successfully") + + messageService, err := services.NewMessageSyncService(wClient, cClient, db.DB) // Pass wClient now + if err != nil { + log.Fatal().Err(err).Msg("Failed to initialize MessageSyncService") + } + log.Info().Msg("MessageSyncService initialized successfully") + + // The initialized clients (wClient, cClient), services (contactService, conversationService, messageService), and db.DB + // can now be passed to handlers, etc., as needed. + + + // Initialize Handlers + // The WuzapiHandler now takes dependencies. + wuzapiHandler := handlers.NewWuzapiHandler(contactService, conversationService, messageService, cfg.WebhookSecret) + + + // Setup HTTP routes + // TODO: Consider using a router like gorilla/mux for more complex routing + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + // This is a handler, logging here would be request-specific. + // For now, simple response is fine. + fmt.Fprintln(w, "Welcome to Wuzapi-Chatwoot Integration! API Server is running.") + }) + http.HandleFunc(cfg.WuzapiWebhookPath, wuzapiHandler.Handle) // Use the Handle method of the struct instance + log.Info().Str("path", cfg.WuzapiWebhookPath).Msg("Registered Wuzapi webhook handler") + + + port := cfg.Port + if port == "" { + port = "8080" // Default port + log.Info().Str("port", port).Msg("Defaulting to port") + } + + log.Info().Str("port", port).Msgf("Server starting on port %s...", port) + if err := http.ListenAndServe(":"+port, nil); err != nil { + log.Fatal().Err(err).Msg("Failed to start server") + } +} diff --git a/wuzapi-chatwoot-integration/pkg/httputil/client.go b/wuzapi-chatwoot-integration/pkg/httputil/client.go new file mode 100644 index 00000000..c47c2667 --- /dev/null +++ b/wuzapi-chatwoot-integration/pkg/httputil/client.go @@ -0,0 +1,24 @@ +package httputil + +import ( + // "github.com/go-resty/resty/v2" + // "time" +) + +// This package provides shared HTTP client utilities. +// For instance, a function to create a new Resty client with common configurations +// (e.g., default timeouts, retry mechanisms, common headers) could be defined here. + +/* +Example of a shared client creation function (if needed later): + +func NewDefaultRestyClient() *resty.Client { + client := resty.New(). + SetTimeout(10 * time.Second). + SetRetryCount(3). + SetRetryWaitTime(5 * time.Second). + SetRetryMaxWaitTime(20 * time.Second) + // Add any other common configurations, like user-agent + return client +} +*/ diff --git a/wuzapi-chatwoot-integration/pkg/logger/logger.go b/wuzapi-chatwoot-integration/pkg/logger/logger.go new file mode 100644 index 00000000..82248c4a --- /dev/null +++ b/wuzapi-chatwoot-integration/pkg/logger/logger.go @@ -0,0 +1,54 @@ +package logger + +import ( + "os" + "time" + + "github.com/rs/zerolog" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" // zerolog's global logger +) + +// InitLogger initializes zerolog's global logger instance. +// It allows for console or JSON output based on the LOG_FORMAT environment variable. +// Log level is also configured via LOG_LEVEL environment variable. +func InitLogger() { + logFormat := os.Getenv("LOG_FORMAT") + logLevelStr := os.Getenv("LOG_LEVEL") + + var level zerolog.Level + switch logLevelStr { + case "debug": + level = zerolog.DebugLevel + case "info": + level = zerolog.InfoLevel + case "warn": + level = zerolog.WarnLevel + case "error": + level = zerolog.ErrorLevel + case "fatal": + level = zerolog.FatalLevel + case "panic": + level = zerolog.PanicLevel + default: + level = zerolog.InfoLevel // Default to info level + } + + zerolog.SetGlobalLevel(level) + + if logFormat != "json" { // Default to console if not "json" + log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339}) + } + // If logFormat is "json", zerolog's default is JSON output to os.Stderr, so no change needed for log.Logger itself. + // We just need to ensure TimeFormat and other global settings are applied if necessary. + // zerolog.TimeFieldFormat = zerolog.TimeFormatUnix // Example: if you want Unix timestamps for JSON + + // Log the initialization event using the configured global logger + log.Info().Str("logFormat", logFormat).Str("logLevel", level.String()).Msg("Logger initialized") +} + +// GetLogger returns the configured global zerolog logger. +// This function is provided for convenience if direct access to log.Logger is not preferred elsewhere. +func GetLogger() zerolog.Logger { + return log.Logger +}