Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions wuzapi-chatwoot-integration/.env.sample
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions wuzapi-chatwoot-integration/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Placeholder Dockerfile
3 changes: 3 additions & 0 deletions wuzapi-chatwoot-integration/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Wuzapi-Chatwoot Integration

This project integrates Wuzapi with Chatwoot.
74 changes: 74 additions & 0 deletions wuzapi-chatwoot-integration/config/config.go
Original file line number Diff line number Diff line change
@@ -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")
}
Comment on lines +62 to +65
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Consider adding validation for required environment variables and returning an error from LoadConfig if they are not found. This would make startup failures more explicit.


// 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
}
22 changes: 22 additions & 0 deletions wuzapi-chatwoot-integration/go.mod
Original file line number Diff line number Diff line change
@@ -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
)
36 changes: 36 additions & 0 deletions wuzapi-chatwoot-integration/go.sum
Original file line number Diff line number Diff line change
@@ -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=
224 changes: 224 additions & 0 deletions wuzapi-chatwoot-integration/internal/adapters/chatwoot/client.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading