diff --git a/README.md b/README.md index 8491ede..7f2c314 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # WhatsHooked -A service that connects to WhatsApp via the whatsmeow API and forwards messages to registered webhooks. Enables two-way communication by allowing webhooks to respond with messages to be sent through WhatsApp. +A service that connects to WhatsApp and forwards messages to registered webhooks. Supports both personal WhatsApp accounts (via whatsmeow) and WhatsApp Business API. Enables two-way communication by allowing webhooks to respond with messages to be sent through WhatsApp. ![1.00](./assets/image/whatshooked.jpg) @@ -12,6 +12,7 @@ A service that connects to WhatsApp via the whatsmeow API and forwards messages ## Phase 1 Features - **Multi-Account Support**: Connect to multiple WhatsApp accounts simultaneously +- **Dual Client Types**: Support for both personal WhatsApp (whatsmeow) and WhatsApp Business API - **Webhook Integration**: Register multiple webhooks to receive WhatsApp messages - **Two-Way Communication**: Webhooks can respond with messages to send back to WhatsApp - **Instance/Config Level Hooks**: Global hooks that receive all messages from all accounts @@ -27,7 +28,9 @@ The project uses an event-driven architecture with the following packages: - **internal/config**: Configuration management and persistence - **internal/logging**: Structured logging using Go's slog package - **internal/events**: Event bus for publish/subscribe messaging between components -- **internal/whatsapp**: WhatsApp client management using whatsmeow +- **internal/whatsapp**: WhatsApp client management (supports both whatsmeow and Business API) + - **whatsmeow/**: Personal WhatsApp client implementation + - **businessapi/**: WhatsApp Business API client implementation - **internal/hooks**: Webhook management and message forwarding - **cmd/server**: Main server application - **cmd/cli**: Command-line interface for management @@ -88,9 +91,23 @@ Edit the configuration file to add your WhatsApp accounts and webhooks: }, "whatsapp": [ { - "id": "account1", + "id": "personal", + "type": "whatsmeow", "phone_number": "+1234567890", - "session_path": "./sessions/account1" + "session_path": "./sessions/personal", + "show_qr": true + }, + { + "id": "business", + "type": "business-api", + "phone_number": "+9876543210", + "business_api": { + "phone_number_id": "123456789012345", + "access_token": "EAAxxxxxxxxxxxx", + "business_account_id": "987654321098765", + "api_version": "v21.0", + "verify_token": "my-secure-verify-token" + } } ], "hooks": [ @@ -122,8 +139,20 @@ Edit the configuration file to add your WhatsApp accounts and webhooks: **WhatsApp Account Configuration:** - `id`: Unique identifier for this account +- `type`: Client type - `"whatsmeow"` for personal or `"business-api"` for Business API (defaults to "whatsmeow") - `phone_number`: Phone number with country code -- `session_path`: Path to store session data + +**For whatsmeow (personal) accounts:** +- `session_path`: Path to store session data (default: `./sessions/{id}`) +- `show_qr`: Display QR code in terminal for pairing (default: false) + +**For business-api accounts:** +- `business_api`: Business API configuration object + - `phone_number_id`: WhatsApp Business Phone Number ID from Meta + - `access_token`: Access token from Meta Business Manager + - `business_account_id`: Business Account ID (optional) + - `api_version`: Graph API version (default: "v21.0") + - `verify_token`: Token for webhook verification (required for receiving messages) **Hook Configuration:** - `id`: Unique identifier for this hook @@ -187,6 +216,146 @@ Clients can provide the API key using either: - All `/api/*` endpoints require authentication when enabled - Both authentication methods can be configured simultaneously - the server will accept either valid credentials or a valid API key +## WhatsApp Business API Setup + +WhatsHooked supports the official WhatsApp Business Cloud API alongside personal WhatsApp accounts. This allows you to use official business phone numbers with enhanced features and reliability. + +### Prerequisites + +1. **Meta Business Account**: Sign up at [Meta Business Suite](https://business.facebook.com/) +2. **WhatsApp Business App**: Create a WhatsApp Business app in the [Meta for Developers](https://developers.facebook.com/) console +3. **Phone Number**: Register a business phone number with WhatsApp Business API + +### Getting Your Credentials + +1. Go to [Meta for Developers](https://developers.facebook.com/) and select your app +2. Navigate to **WhatsApp** → **API Setup** +3. Obtain the following: + - **Phone Number ID**: Found in the API Setup page + - **WhatsApp Business Account ID**: Found in the API Setup page (optional but recommended) + - **Access Token**: Generate a permanent token (not the temporary 24-hour token) + - **API Version**: Use the current stable version (e.g., `v21.0`) + +### Configuring the Account + +Add a Business API account to your `config.json`: + +```json +{ + "whatsapp": [ + { + "id": "business", + "type": "business-api", + "phone_number": "+1234567890", + "business_api": { + "phone_number_id": "123456789012345", + "access_token": "EAAxxxxxxxxxxxx_your_permanent_token", + "business_account_id": "987654321098765", + "api_version": "v21.0", + "verify_token": "my-secure-random-token-12345" + } + } + ] +} +``` + +**Important Notes:** +- Use a **permanent access token**, not the temporary 24-hour token +- The `verify_token` is a random string you create - it will be used to verify Meta's webhook requests +- Keep your access token secure and never commit it to version control + +### Setting Up Webhooks (Required for Receiving Messages) + +To receive incoming messages from WhatsApp Business API, you must register your webhook with Meta: + +1. **Start the WhatsHooked server** with your Business API configuration +2. **Ensure your server is publicly accessible** (use ngrok for testing): + ```bash + ngrok http 8080 + ``` +3. **In Meta for Developers**, go to **WhatsApp** → **Configuration** +4. **Add Webhook URL**: + - **Callback URL**: `https://your-domain.com/webhooks/whatsapp/{accountID}` + - Replace `your-domain.com` with your public domain or ngrok URL + - Replace `{accountID}` with your account ID from config (e.g., `business`) + - Example: `https://abc123.ngrok.io/webhooks/whatsapp/business` + - **Verify Token**: Enter the same `verify_token` from your config +5. **Subscribe to Webhook Fields**: + - Check **messages** (required for receiving messages) + - Check **message_status** (optional, for delivery/read receipts) +6. Click **Verify and Save** + +### Testing Your Business API Connection + +Once configured, start the server and the Business API account will connect automatically: + +```bash +./bin/whatshook-server -config config.json +``` + +Look for logs indicating successful connection: +``` +Business API client connected account_id=business phone=+1234567890 +``` + +Send a test message: +```bash +./bin/whatshook-cli send +# Select your business account +# Enter recipient phone number +# Type your message +``` + +### Business API Features + +**Supported:** +- ✅ Send/receive text messages +- ✅ Send/receive images with captions +- ✅ Send/receive videos with captions +- ✅ Send/receive documents with filenames +- ✅ Media upload via Meta CDN +- ✅ Delivery and read receipts +- ✅ Event publishing to webhooks (same format as whatsmeow) + +**Differences from whatsmeow:** +- No QR code pairing (uses access token authentication) +- Rate limits apply based on your Meta Business tier +- Official support from Meta +- Better reliability for business use cases +- Costs apply based on conversation pricing + +### Running Both Client Types Simultaneously + +You can run both personal (whatsmeow) and Business API accounts at the same time: + +```json +{ + "whatsapp": [ + { + "id": "personal", + "type": "whatsmeow", + "phone_number": "+1234567890", + "session_path": "./sessions/personal" + }, + { + "id": "business", + "type": "business-api", + "phone_number": "+9876543210", + "business_api": { + "phone_number_id": "123456789012345", + "access_token": "EAAxxxxxxxxxxxx" + } + } + ] +} +``` + +Both accounts will: +- Receive messages independently +- Trigger the same webhooks +- Publish identical event formats +- Support the same API endpoints + ## Usage ### Starting the Server @@ -365,17 +534,21 @@ Examples with `default_country_code: "27"`: The server exposes the following HTTP endpoints: +**Public Endpoints:** - `GET /health` - Health check (no authentication required) -- `GET /api/hooks` - List all hooks (requires authentication if enabled) -- `POST /api/hooks/add` - Add a new hook (requires authentication if enabled) -- `POST /api/hooks/remove` - Remove a hook (requires authentication if enabled) -- `GET /api/accounts` - List all WhatsApp accounts (requires authentication if enabled) -- `POST /api/accounts/add` - Add a new WhatsApp account (requires authentication if enabled) -- `POST /api/send` - Send a message (requires authentication if enabled) -- `POST /api/send/image` - Send an image (requires authentication if enabled) -- `POST /api/send/video` - Send a video (requires authentication if enabled) -- `POST /api/send/document` - Send a document (requires authentication if enabled) -- `GET /api/media/{accountID}/{filename}` - Serve media files (requires authentication if enabled) +- `GET/POST /webhooks/whatsapp/{accountID}` - Business API webhook verification and events (no authentication, validated by Meta's verify_token) + +**Protected Endpoints (require authentication if enabled):** +- `GET /api/hooks` - List all hooks +- `POST /api/hooks/add` - Add a new hook +- `POST /api/hooks/remove` - Remove a hook +- `GET /api/accounts` - List all WhatsApp accounts +- `POST /api/accounts/add` - Add a new WhatsApp account +- `POST /api/send` - Send a message +- `POST /api/send/image` - Send an image +- `POST /api/send/video` - Send a video +- `POST /api/send/document` - Send a document +- `GET /api/media/{accountID}/{filename}` - Serve media files ## WhatsApp JID Format @@ -393,17 +566,30 @@ The server accepts both full JID format and plain phone numbers. When using plai ``` whatshooked/ ├── cmd/ -│ ├── server/ # Main server application -│ └── cli/ # CLI tool +│ ├── server/ # Main server application +│ │ ├── main.go +│ │ ├── routes.go +│ │ ├── routes_*.go # Route handlers +│ │ └── routes_businessapi.go # Business API webhooks +│ └── cli/ # CLI tool ├── internal/ -│ ├── config/ # Configuration management -│ ├── events/ # Event bus and event types -│ ├── logging/ # Structured logging -│ ├── whatsapp/ # WhatsApp client management -│ ├── hooks/ # Webhook management -│ └── utils/ # Utility functions (phone formatting, etc.) -├── config.example.json # Example configuration -└── go.mod # Go module definition +│ ├── config/ # Configuration management +│ ├── events/ # Event bus and event types +│ ├── logging/ # Structured logging +│ ├── whatsapp/ # WhatsApp client management +│ │ ├── interface.go # Client interface +│ │ ├── manager.go # Multi-client manager +│ │ ├── whatsmeow/ # Personal WhatsApp (QR code) +│ │ │ └── client.go +│ │ └── businessapi/ # WhatsApp Business API +│ │ ├── client.go # API client +│ │ ├── types.go # Request/response types +│ │ ├── events.go # Webhook processing +│ │ └── media.go # Media upload/download +│ ├── hooks/ # Webhook management +│ └── utils/ # Utility functions (phone formatting, etc.) +├── config.example.json # Example configuration +└── go.mod # Go module definition ``` ### Event Types diff --git a/TODO.md b/TODO.md index 62e9666..42a02a9 100644 --- a/TODO.md +++ b/TODO.md @@ -4,8 +4,10 @@ - [✔️] Docker Server Support with docker-compose.yml (Basic Config from .ENV file) - [✔️] Authentication options for cli - [✔️] **Refactor** the code to make it more readable and maintainable. (Split server, hooks and routes. Split CLI into commands etc. Common connection code.) -- [ ] Whatsapp Business API support add +- [✔️] Whatsapp Business API support add - [ ] Optional Postgres server connection for Whatsmeo - [ ] Optional Postgres server,database for event saving and hook registration - [✔️] Optional Event logging into directory for each type -- [ ] MQTT Support for events (To connect it to home assistant, have to prototype. Incoming message/outgoing messages) \ No newline at end of file +- [ ] MQTT Support for events (To connect it to home assistant, have to prototype. Incoming message/outgoing messages) +- [ ] Refactor into pkg to be able to use the system as a client library instead of starting a server +- [ ] HTTPS Server with certbot support, self signed certificate generation or custom certificate paths. \ No newline at end of file diff --git a/cmd/server/routes.go b/cmd/server/routes.go index 14cdc01..25da23a 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -32,6 +32,9 @@ func (s *Server) setupRoutes() *http.ServeMux { // Serve media files (with auth) mux.HandleFunc("/api/media/", s.handleServeMedia) + // Business API webhooks (no auth - Meta validates via verify_token) + mux.HandleFunc("/webhooks/whatsapp/", s.handleBusinessAPIWebhook) + return mux } diff --git a/cmd/server/routes_businessapi.go b/cmd/server/routes_businessapi.go new file mode 100644 index 0000000..c18d3d7 --- /dev/null +++ b/cmd/server/routes_businessapi.go @@ -0,0 +1,151 @@ +package main + +import ( + "net/http" + "strings" + + "git.warky.dev/wdevs/whatshooked/internal/logging" + "git.warky.dev/wdevs/whatshooked/internal/whatsapp/businessapi" +) + +// handleBusinessAPIWebhook handles both verification (GET) and webhook events (POST) +func (s *Server) handleBusinessAPIWebhook(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + s.handleBusinessAPIWebhookVerify(w, r) + return + } + + if r.Method == http.MethodPost { + s.handleBusinessAPIWebhookEvent(w, r) + return + } + + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) +} + +// handleBusinessAPIWebhookVerify handles webhook verification from Meta +// GET /webhooks/whatsapp/{accountID}?hub.mode=subscribe&hub.verify_token=XXX&hub.challenge=YYY +func (s *Server) handleBusinessAPIWebhookVerify(w http.ResponseWriter, r *http.Request) { + // Extract account ID from URL path + accountID := extractAccountIDFromPath(r.URL.Path) + if accountID == "" { + http.Error(w, "Account ID required in path", http.StatusBadRequest) + return + } + + // Get the account configuration + var accountConfig *struct { + ID string + Type string + VerifyToken string + } + + for _, cfg := range s.config.WhatsApp { + if cfg.ID == accountID && cfg.Type == "business-api" { + if cfg.BusinessAPI != nil { + accountConfig = &struct { + ID string + Type string + VerifyToken string + }{ + ID: cfg.ID, + Type: cfg.Type, + VerifyToken: cfg.BusinessAPI.VerifyToken, + } + break + } + } + } + + if accountConfig == nil { + logging.Error("Business API account not found or not configured", "account_id", accountID) + http.Error(w, "Account not found", http.StatusNotFound) + return + } + + // Get query parameters + mode := r.URL.Query().Get("hub.mode") + token := r.URL.Query().Get("hub.verify_token") + challenge := r.URL.Query().Get("hub.challenge") + + logging.Info("Webhook verification request", + "account_id", accountID, + "mode", mode, + "has_challenge", challenge != "") + + // Verify the token matches + if mode == "subscribe" && token == accountConfig.VerifyToken { + logging.Info("Webhook verification successful", "account_id", accountID) + w.WriteHeader(http.StatusOK) + w.Write([]byte(challenge)) + return + } + + logging.Warn("Webhook verification failed", + "account_id", accountID, + "mode", mode, + "token_match", token == accountConfig.VerifyToken) + http.Error(w, "Forbidden", http.StatusForbidden) +} + +// handleBusinessAPIWebhookEvent handles incoming webhook events from Meta +// POST /webhooks/whatsapp/{accountID} +func (s *Server) handleBusinessAPIWebhookEvent(w http.ResponseWriter, r *http.Request) { + // Extract account ID from URL path + accountID := extractAccountIDFromPath(r.URL.Path) + if accountID == "" { + http.Error(w, "Account ID required in path", http.StatusBadRequest) + return + } + + // Get the client from the manager + client, exists := s.whatsappMgr.GetClient(accountID) + if !exists { + logging.Error("Client not found for webhook", "account_id", accountID) + http.Error(w, "Account not found", http.StatusNotFound) + return + } + + // Verify it's a Business API client + if client.GetType() != "business-api" { + logging.Error("Account is not a Business API client", "account_id", accountID, "type", client.GetType()) + http.Error(w, "Not a Business API account", http.StatusBadRequest) + return + } + + // Cast to Business API client to access HandleWebhook + baClient, ok := client.(*businessapi.Client) + if !ok { + logging.Error("Failed to cast to Business API client", "account_id", accountID) + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + + // Process the webhook + if err := baClient.HandleWebhook(r); err != nil { + logging.Error("Failed to process webhook", "account_id", accountID, "error", err) + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + + // Return 200 OK to acknowledge receipt + w.WriteHeader(http.StatusOK) + w.Write([]byte("OK")) +} + +// extractAccountIDFromPath extracts the account ID from the URL path +// Example: /webhooks/whatsapp/business -> "business" +func extractAccountIDFromPath(path string) string { + // Remove trailing slash if present + path = strings.TrimSuffix(path, "/") + + // Split by / + parts := strings.Split(path, "/") + + // Expected format: /webhooks/whatsapp/{accountID} + if len(parts) >= 4 { + return parts[3] + } + + return "" +} diff --git a/config.example.json b/config.example.json index 8979288..a6cd58a 100644 --- a/config.example.json +++ b/config.example.json @@ -9,10 +9,23 @@ }, "whatsapp": [ { - "id": "acc1", + "id": "personal", + "type": "whatsmeow", "phone_number": "+1234567890", - "session_path": "./sessions/account1", + "session_path": "./sessions/personal", "show_qr": true + }, + { + "id": "business", + "type": "business-api", + "phone_number": "+9876543210", + "business_api": { + "phone_number_id": "123456789012345", + "access_token": "EAAxxxxxxxxxxxx_your_access_token_here", + "business_account_id": "987654321098765", + "api_version": "v21.0", + "verify_token": "my-secure-verify-token-12345" + } } ], "hooks": [ diff --git a/internal/config/config.go b/internal/config/config.go index 32dd942..4b157a8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -28,10 +28,22 @@ type ServerConfig struct { // WhatsAppConfig holds configuration for a WhatsApp account type WhatsAppConfig struct { - ID string `json:"id"` - PhoneNumber string `json:"phone_number"` - SessionPath string `json:"session_path"` - ShowQR bool `json:"show_qr,omitempty"` + ID string `json:"id"` + Type string `json:"type"` // "whatsmeow" or "business-api" + PhoneNumber string `json:"phone_number"` + SessionPath string `json:"session_path,omitempty"` + ShowQR bool `json:"show_qr,omitempty"` + BusinessAPI *BusinessAPIConfig `json:"business_api,omitempty"` +} + +// BusinessAPIConfig holds configuration for WhatsApp Business API +type BusinessAPIConfig struct { + PhoneNumberID string `json:"phone_number_id"` + AccessToken string `json:"access_token"` + BusinessAccountID string `json:"business_account_id,omitempty"` + APIVersion string `json:"api_version,omitempty"` // Default: v21.0 + WebhookPath string `json:"webhook_path,omitempty"` + VerifyToken string `json:"verify_token,omitempty"` } // Hook represents a registered webhook @@ -114,6 +126,19 @@ func Load(path string) (*Config, error) { cfg.Database.SQLitePath = "./data/events.db" } + // Default WhatsApp account type to whatsmeow for backwards compatibility + for i := range cfg.WhatsApp { + if cfg.WhatsApp[i].Type == "" { + cfg.WhatsApp[i].Type = "whatsmeow" + } + // Set default API version for Business API + if cfg.WhatsApp[i].Type == "business-api" && cfg.WhatsApp[i].BusinessAPI != nil { + if cfg.WhatsApp[i].BusinessAPI.APIVersion == "" { + cfg.WhatsApp[i].BusinessAPI.APIVersion = "v21.0" + } + } + } + return &cfg, nil } diff --git a/internal/whatsapp/businessapi/client.go b/internal/whatsapp/businessapi/client.go new file mode 100644 index 0000000..6110f14 --- /dev/null +++ b/internal/whatsapp/businessapi/client.go @@ -0,0 +1,354 @@ +package businessapi + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "sync" + "time" + + "git.warky.dev/wdevs/whatshooked/internal/config" + "git.warky.dev/wdevs/whatshooked/internal/events" + "git.warky.dev/wdevs/whatshooked/internal/logging" + + "go.mau.fi/whatsmeow/types" +) + +// Client represents a WhatsApp Business API client +type Client struct { + id string + phoneNumber string + config config.BusinessAPIConfig + httpClient *http.Client + eventBus *events.EventBus + mediaConfig config.MediaConfig + connected bool + mu sync.RWMutex +} + +// NewClient creates a new Business API client +func NewClient(cfg config.WhatsAppConfig, eventBus *events.EventBus, mediaConfig config.MediaConfig) (*Client, error) { + if cfg.Type != "business-api" { + return nil, fmt.Errorf("invalid client type for business-api: %s", cfg.Type) + } + + if cfg.BusinessAPI == nil { + return nil, fmt.Errorf("business_api configuration is required for business-api type") + } + + // Validate required fields + if cfg.BusinessAPI.PhoneNumberID == "" { + return nil, fmt.Errorf("phone_number_id is required") + } + if cfg.BusinessAPI.AccessToken == "" { + return nil, fmt.Errorf("access_token is required") + } + + // Set default API version + if cfg.BusinessAPI.APIVersion == "" { + cfg.BusinessAPI.APIVersion = "v21.0" + } + + return &Client{ + id: cfg.ID, + phoneNumber: cfg.PhoneNumber, + config: *cfg.BusinessAPI, + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + eventBus: eventBus, + mediaConfig: mediaConfig, + connected: false, + }, nil +} + +// Connect validates the Business API credentials +func (c *Client) Connect(ctx context.Context) error { + // Validate credentials by making a test request to get phone number details + url := fmt.Sprintf("https://graph.facebook.com/%s/%s", + c.config.APIVersion, + c.config.PhoneNumberID) + + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+c.config.AccessToken) + + resp, err := c.httpClient.Do(req) + if err != nil { + c.eventBus.Publish(events.WhatsAppPairFailedEvent(ctx, c.id, err)) + return fmt.Errorf("failed to validate credentials: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + err := fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(body)) + c.eventBus.Publish(events.WhatsAppPairFailedEvent(ctx, c.id, err)) + return err + } + + c.mu.Lock() + c.connected = true + c.mu.Unlock() + + logging.Info("Business API client connected", "account_id", c.id, "phone", c.phoneNumber) + c.eventBus.Publish(events.WhatsAppConnectedEvent(ctx, c.id, c.phoneNumber)) + return nil +} + +// Disconnect closes the Business API client +func (c *Client) Disconnect() error { + c.mu.Lock() + c.connected = false + c.mu.Unlock() + + logging.Info("Business API client disconnected", "account_id", c.id) + return nil +} + +// IsConnected returns whether the client is connected +func (c *Client) IsConnected() bool { + c.mu.RLock() + defer c.mu.RUnlock() + return c.connected +} + +// GetID returns the client ID +func (c *Client) GetID() string { + return c.id +} + +// GetPhoneNumber returns the phone number +func (c *Client) GetPhoneNumber() string { + return c.phoneNumber +} + +// GetType returns the client type +func (c *Client) GetType() string { + return "business-api" +} + +// SendTextMessage sends a text message via Business API +func (c *Client) SendTextMessage(ctx context.Context, jid types.JID, text string) (string, error) { + if ctx == nil { + ctx = context.Background() + } + + // Convert JID to phone number + phoneNumber := jidToPhoneNumber(jid) + + // Create request + reqBody := SendMessageRequest{ + MessagingProduct: "whatsapp", + To: phoneNumber, + Type: "text", + Text: &TextObject{ + Body: text, + }, + } + + messageID, err := c.sendMessage(ctx, reqBody) + if err != nil { + c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, phoneNumber, text, err)) + return "", err + } + + logging.Debug("Message sent via Business API", "account_id", c.id, "to", phoneNumber) + c.eventBus.Publish(events.MessageSentEvent(ctx, c.id, messageID, phoneNumber, text)) + return messageID, nil +} + +// SendImage sends an image message via Business API +func (c *Client) SendImage(ctx context.Context, jid types.JID, imageData []byte, mimeType string, caption string) (string, error) { + if ctx == nil { + ctx = context.Background() + } + + phoneNumber := jidToPhoneNumber(jid) + + // Upload media first + mediaID, err := c.uploadMedia(ctx, imageData, mimeType) + if err != nil { + c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, phoneNumber, caption, err)) + return "", fmt.Errorf("failed to upload image: %w", err) + } + + // Send message with media ID + reqBody := SendMessageRequest{ + MessagingProduct: "whatsapp", + To: phoneNumber, + Type: "image", + Image: &MediaObject{ + ID: mediaID, + Caption: caption, + }, + } + + messageID, err := c.sendMessage(ctx, reqBody) + if err != nil { + c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, phoneNumber, caption, err)) + return "", err + } + + logging.Debug("Image sent via Business API", "account_id", c.id, "to", phoneNumber) + c.eventBus.Publish(events.MessageSentEvent(ctx, c.id, messageID, phoneNumber, caption)) + return messageID, nil +} + +// SendVideo sends a video message via Business API +func (c *Client) SendVideo(ctx context.Context, jid types.JID, videoData []byte, mimeType string, caption string) (string, error) { + if ctx == nil { + ctx = context.Background() + } + + phoneNumber := jidToPhoneNumber(jid) + + // Upload media first + mediaID, err := c.uploadMedia(ctx, videoData, mimeType) + if err != nil { + c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, phoneNumber, caption, err)) + return "", fmt.Errorf("failed to upload video: %w", err) + } + + // Send message with media ID + reqBody := SendMessageRequest{ + MessagingProduct: "whatsapp", + To: phoneNumber, + Type: "video", + Video: &MediaObject{ + ID: mediaID, + Caption: caption, + }, + } + + messageID, err := c.sendMessage(ctx, reqBody) + if err != nil { + c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, phoneNumber, caption, err)) + return "", err + } + + logging.Debug("Video sent via Business API", "account_id", c.id, "to", phoneNumber) + c.eventBus.Publish(events.MessageSentEvent(ctx, c.id, messageID, phoneNumber, caption)) + return messageID, nil +} + +// SendDocument sends a document message via Business API +func (c *Client) SendDocument(ctx context.Context, jid types.JID, documentData []byte, mimeType string, filename string, caption string) (string, error) { + if ctx == nil { + ctx = context.Background() + } + + phoneNumber := jidToPhoneNumber(jid) + + // Upload media first + mediaID, err := c.uploadMedia(ctx, documentData, mimeType) + if err != nil { + c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, phoneNumber, caption, err)) + return "", fmt.Errorf("failed to upload document: %w", err) + } + + // Send message with media ID + reqBody := SendMessageRequest{ + MessagingProduct: "whatsapp", + To: phoneNumber, + Type: "document", + Document: &DocumentObject{ + ID: mediaID, + Caption: caption, + Filename: filename, + }, + } + + messageID, err := c.sendMessage(ctx, reqBody) + if err != nil { + c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, phoneNumber, caption, err)) + return "", err + } + + logging.Debug("Document sent via Business API", "account_id", c.id, "to", phoneNumber, "filename", filename) + c.eventBus.Publish(events.MessageSentEvent(ctx, c.id, messageID, phoneNumber, caption)) + return messageID, nil +} + +// sendMessage sends a message request to the Business API +func (c *Client) sendMessage(ctx context.Context, reqBody SendMessageRequest) (string, error) { + url := fmt.Sprintf("https://graph.facebook.com/%s/%s/messages", + c.config.APIVersion, + c.config.PhoneNumberID) + + jsonData, err := json.Marshal(reqBody) + if err != nil { + return "", fmt.Errorf("failed to marshal request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonData)) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+c.config.AccessToken) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + var errResp ErrorResponse + if err := json.Unmarshal(body, &errResp); err == nil { + return "", fmt.Errorf("API error: %s (code: %d)", errResp.Error.Message, errResp.Error.Code) + } + return "", fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(body)) + } + + var sendResp SendMessageResponse + if err := json.Unmarshal(body, &sendResp); err != nil { + return "", fmt.Errorf("failed to parse response: %w", err) + } + + if len(sendResp.Messages) == 0 { + return "", fmt.Errorf("no message ID in response") + } + + return sendResp.Messages[0].ID, nil +} + +// jidToPhoneNumber converts a WhatsApp JID to E.164 phone number format +func jidToPhoneNumber(jid types.JID) string { + // JID format is like "27123456789@s.whatsapp.net" + // Extract the phone number part before @ + phone := jid.User + + // Ensure it starts with + for E.164 + if !strings.HasPrefix(phone, "+") { + phone = "+" + phone + } + + return phone +} + +// phoneNumberToJID converts an E.164 phone number to WhatsApp JID +func phoneNumberToJID(phoneNumber string) types.JID { + // Remove + if present + phone := strings.TrimPrefix(phoneNumber, "+") + + // Create JID + return types.JID{ + User: phone, + Server: types.DefaultUserServer, // "s.whatsapp.net" + } +} diff --git a/internal/whatsapp/businessapi/events.go b/internal/whatsapp/businessapi/events.go new file mode 100644 index 0000000..27e1147 --- /dev/null +++ b/internal/whatsapp/businessapi/events.go @@ -0,0 +1,288 @@ +package businessapi + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strconv" + "time" + + "git.warky.dev/wdevs/whatshooked/internal/events" + "git.warky.dev/wdevs/whatshooked/internal/logging" +) + +// HandleWebhook processes incoming webhook events from WhatsApp Business API +func (c *Client) HandleWebhook(r *http.Request) error { + body, err := io.ReadAll(r.Body) + if err != nil { + return fmt.Errorf("failed to read request body: %w", err) + } + + var payload WebhookPayload + if err := json.Unmarshal(body, &payload); err != nil { + return fmt.Errorf("failed to parse webhook payload: %w", err) + } + + // Process each entry + for _, entry := range payload.Entry { + for _, change := range entry.Changes { + c.processChange(change) + } + } + + return nil +} + +// processChange processes a webhook change +func (c *Client) processChange(change WebhookChange) { + ctx := context.Background() + + // Process messages + for _, msg := range change.Value.Messages { + c.processMessage(ctx, msg, change.Value.Contacts) + } + + // Process statuses + for _, status := range change.Value.Statuses { + c.processStatus(ctx, status) + } +} + +// processMessage processes an incoming message +func (c *Client) processMessage(ctx context.Context, msg WebhookMessage, contacts []WebhookContact) { + // Get sender name from contacts + senderName := "" + for _, contact := range contacts { + if contact.WaID == msg.From { + senderName = contact.Profile.Name + break + } + } + + // Parse timestamp + timestamp := c.parseTimestamp(msg.Timestamp) + + var text string + var messageType string + var mimeType string + var filename string + var mediaBase64 string + var mediaURL string + + // Process based on message type + switch msg.Type { + case "text": + if msg.Text != nil { + text = msg.Text.Body + } + messageType = "text" + + case "image": + if msg.Image != nil { + messageType = "image" + mimeType = msg.Image.MimeType + text = msg.Image.Caption + + // Download and process media + data, _, err := c.downloadMedia(ctx, msg.Image.ID) + if err != nil { + logging.Error("Failed to download image", "account_id", c.id, "media_id", msg.Image.ID, "error", err) + } else { + filename, mediaURL = c.processMediaData(msg.ID, data, mimeType, &mediaBase64) + } + } + + case "video": + if msg.Video != nil { + messageType = "video" + mimeType = msg.Video.MimeType + text = msg.Video.Caption + + // Download and process media + data, _, err := c.downloadMedia(ctx, msg.Video.ID) + if err != nil { + logging.Error("Failed to download video", "account_id", c.id, "media_id", msg.Video.ID, "error", err) + } else { + filename, mediaURL = c.processMediaData(msg.ID, data, mimeType, &mediaBase64) + } + } + + case "document": + if msg.Document != nil { + messageType = "document" + mimeType = msg.Document.MimeType + text = msg.Document.Caption + filename = msg.Document.Filename + + // Download and process media + data, _, err := c.downloadMedia(ctx, msg.Document.ID) + if err != nil { + logging.Error("Failed to download document", "account_id", c.id, "media_id", msg.Document.ID, "error", err) + } else { + filename, mediaURL = c.processMediaData(msg.ID, data, mimeType, &mediaBase64) + } + } + + default: + logging.Warn("Unsupported message type", "account_id", c.id, "type", msg.Type) + return + } + + // Publish message received event + c.eventBus.Publish(events.MessageReceivedEvent( + ctx, + c.id, + msg.ID, + msg.From, + msg.From, // For Business API, chat is same as sender for individual messages + text, + timestamp, + false, // Business API doesn't indicate groups in this webhook + "", + senderName, + messageType, + mimeType, + filename, + mediaBase64, + mediaURL, + )) + + logging.Debug("Message received via Business API", "account_id", c.id, "from", msg.From, "type", messageType) +} + +// processStatus processes a message status update +func (c *Client) processStatus(ctx context.Context, status WebhookStatus) { + timestamp := c.parseTimestamp(status.Timestamp) + + switch status.Status { + case "sent": + c.eventBus.Publish(events.MessageSentEvent(ctx, c.id, status.ID, status.RecipientID, "")) + logging.Debug("Message sent status", "account_id", c.id, "message_id", status.ID) + + case "delivered": + c.eventBus.Publish(events.MessageDeliveredEvent(ctx, c.id, status.ID, status.RecipientID, timestamp)) + logging.Debug("Message delivered", "account_id", c.id, "message_id", status.ID) + + case "read": + c.eventBus.Publish(events.MessageReadEvent(ctx, c.id, status.ID, status.RecipientID, timestamp)) + logging.Debug("Message read", "account_id", c.id, "message_id", status.ID) + + case "failed": + errMsg := "unknown error" + if len(status.Errors) > 0 { + errMsg = fmt.Sprintf("%s (code: %d)", status.Errors[0].Title, status.Errors[0].Code) + } + c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, status.RecipientID, "", fmt.Errorf("%s", errMsg))) + logging.Error("Message failed", "account_id", c.id, "message_id", status.ID, "error", errMsg) + + default: + logging.Debug("Unknown status type", "account_id", c.id, "status", status.Status) + } +} + +// parseTimestamp parses a Unix timestamp string to time.Time +func (c *Client) parseTimestamp(ts string) time.Time { + unix, err := strconv.ParseInt(ts, 10, 64) + if err != nil { + logging.Warn("Failed to parse timestamp", "timestamp", ts, "error", err) + return time.Now() + } + return time.Unix(unix, 0) +} + +// processMediaData processes media based on the configured mode +func (c *Client) processMediaData(messageID string, data []byte, mimeType string, mediaBase64 *string) (string, string) { + mode := c.mediaConfig.Mode + var filename string + var mediaURL string + + // Generate filename + ext := getExtensionFromMimeType(mimeType) + hash := sha256.Sum256(data) + hashStr := hex.EncodeToString(hash[:8]) + filename = fmt.Sprintf("%s_%s%s", messageID, hashStr, ext) + + // Handle base64 mode + if mode == "base64" || mode == "both" { + *mediaBase64 = base64.StdEncoding.EncodeToString(data) + } + + // Handle link mode + if mode == "link" || mode == "both" { + // Save file to disk + filePath, err := c.saveMediaFile(messageID, data, mimeType) + if err != nil { + logging.Error("Failed to save media file", "account_id", c.id, "message_id", messageID, "error", err) + } else { + filename = filepath.Base(filePath) + mediaURL = c.generateMediaURL(messageID, filename) + } + } + + return filename, mediaURL +} + +// saveMediaFile saves media data to disk +func (c *Client) saveMediaFile(messageID string, data []byte, mimeType string) (string, error) { + mediaDir := filepath.Join(c.mediaConfig.DataPath, c.id) + if err := os.MkdirAll(mediaDir, 0755); err != nil { + return "", fmt.Errorf("failed to create media directory: %w", err) + } + + hash := sha256.Sum256(data) + hashStr := hex.EncodeToString(hash[:8]) + ext := getExtensionFromMimeType(mimeType) + filename := fmt.Sprintf("%s_%s%s", messageID, hashStr, ext) + + filePath := filepath.Join(mediaDir, filename) + + if err := os.WriteFile(filePath, data, 0644); err != nil { + return "", fmt.Errorf("failed to write media file: %w", err) + } + + return filePath, nil +} + +// generateMediaURL generates a URL for accessing stored media +func (c *Client) generateMediaURL(messageID, filename string) string { + baseURL := c.mediaConfig.BaseURL + if baseURL == "" { + baseURL = "http://localhost:8080" + } + return fmt.Sprintf("%s/api/media/%s/%s", baseURL, c.id, filename) +} + +// getExtensionFromMimeType returns the file extension for a given MIME type +func getExtensionFromMimeType(mimeType string) string { + extensions := map[string]string{ + "image/jpeg": ".jpg", + "image/png": ".png", + "image/gif": ".gif", + "image/webp": ".webp", + "video/mp4": ".mp4", + "video/mpeg": ".mpeg", + "video/webm": ".webm", + "video/3gpp": ".3gp", + "application/pdf": ".pdf", + "application/msword": ".doc", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx", + "application/vnd.ms-excel": ".xls", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx", + "text/plain": ".txt", + "application/json": ".json", + "audio/mpeg": ".mp3", + "audio/ogg": ".ogg", + } + + if ext, ok := extensions[mimeType]; ok { + return ext + } + return "" +} diff --git a/internal/whatsapp/businessapi/media.go b/internal/whatsapp/businessapi/media.go new file mode 100644 index 0000000..1a4e0f9 --- /dev/null +++ b/internal/whatsapp/businessapi/media.go @@ -0,0 +1,138 @@ +package businessapi + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" +) + +// uploadMedia uploads media to the Business API and returns the media ID +func (c *Client) uploadMedia(ctx context.Context, data []byte, mimeType string) (string, error) { + url := fmt.Sprintf("https://graph.facebook.com/%s/%s/media", + c.config.APIVersion, + c.config.PhoneNumberID) + + // Create multipart form data + var requestBody bytes.Buffer + writer := multipart.NewWriter(&requestBody) + + // Add the file + part, err := writer.CreateFormFile("file", "media") + if err != nil { + return "", fmt.Errorf("failed to create form file: %w", err) + } + + if _, err := part.Write(data); err != nil { + return "", fmt.Errorf("failed to write file data: %w", err) + } + + // Add messaging_product field + if err := writer.WriteField("messaging_product", "whatsapp"); err != nil { + return "", fmt.Errorf("failed to write messaging_product field: %w", err) + } + + // Add type field (mime type) + if err := writer.WriteField("type", mimeType); err != nil { + return "", fmt.Errorf("failed to write type field: %w", err) + } + + if err := writer.Close(); err != nil { + return "", fmt.Errorf("failed to close multipart writer: %w", err) + } + + // Create request + req, err := http.NewRequestWithContext(ctx, "POST", url, &requestBody) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+c.config.AccessToken) + req.Header.Set("Content-Type", writer.FormDataContentType()) + + // Send request + resp, err := c.httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("failed to upload media: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + var errResp ErrorResponse + if err := json.Unmarshal(body, &errResp); err == nil { + return "", fmt.Errorf("upload error: %s (code: %d)", errResp.Error.Message, errResp.Error.Code) + } + return "", fmt.Errorf("upload failed with status %d: %s", resp.StatusCode, string(body)) + } + + var uploadResp MediaUploadResponse + if err := json.Unmarshal(body, &uploadResp); err != nil { + return "", fmt.Errorf("failed to parse upload response: %w", err) + } + + return uploadResp.ID, nil +} + +// downloadMedia downloads media from the Business API using the media ID +func (c *Client) downloadMedia(ctx context.Context, mediaID string) ([]byte, string, error) { + // Step 1: Get the media URL + url := fmt.Sprintf("https://graph.facebook.com/%s/%s", + c.config.APIVersion, + mediaID) + + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, "", fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+c.config.AccessToken) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, "", fmt.Errorf("failed to get media URL: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, "", fmt.Errorf("failed to get media URL, status %d: %s", resp.StatusCode, string(body)) + } + + var mediaResp MediaURLResponse + if err := json.NewDecoder(resp.Body).Decode(&mediaResp); err != nil { + return nil, "", fmt.Errorf("failed to parse media URL response: %w", err) + } + + // Step 2: Download from the CDN URL + downloadReq, err := http.NewRequestWithContext(ctx, "GET", mediaResp.URL, nil) + if err != nil { + return nil, "", fmt.Errorf("failed to create download request: %w", err) + } + + downloadReq.Header.Set("Authorization", "Bearer "+c.config.AccessToken) + + downloadResp, err := c.httpClient.Do(downloadReq) + if err != nil { + return nil, "", fmt.Errorf("failed to download media: %w", err) + } + defer downloadResp.Body.Close() + + if downloadResp.StatusCode != http.StatusOK { + return nil, "", fmt.Errorf("failed to download media, status %d", downloadResp.StatusCode) + } + + data, err := io.ReadAll(downloadResp.Body) + if err != nil { + return nil, "", fmt.Errorf("failed to read media data: %w", err) + } + + return data, mediaResp.MimeType, nil +} diff --git a/internal/whatsapp/businessapi/types.go b/internal/whatsapp/businessapi/types.go new file mode 100644 index 0000000..ae3c000 --- /dev/null +++ b/internal/whatsapp/businessapi/types.go @@ -0,0 +1,193 @@ +package businessapi + +// SendMessageRequest represents a request to send a text message via Business API +type SendMessageRequest struct { + MessagingProduct string `json:"messaging_product"` // Always "whatsapp" + RecipientType string `json:"recipient_type,omitempty"` // "individual" + To string `json:"to"` // Phone number in E.164 format + Type string `json:"type"` // "text", "image", "video", "document" + Text *TextObject `json:"text,omitempty"` + Image *MediaObject `json:"image,omitempty"` + Video *MediaObject `json:"video,omitempty"` + Document *DocumentObject `json:"document,omitempty"` +} + +// TextObject represents a text message +type TextObject struct { + Body string `json:"body"` +} + +// MediaObject represents media (image/video) message +type MediaObject struct { + ID string `json:"id,omitempty"` // Media ID (from upload) + Link string `json:"link,omitempty"` // Or direct URL + Caption string `json:"caption,omitempty"` +} + +// DocumentObject represents a document message +type DocumentObject struct { + ID string `json:"id,omitempty"` // Media ID (from upload) + Link string `json:"link,omitempty"` // Or direct URL + Caption string `json:"caption,omitempty"` + Filename string `json:"filename,omitempty"` +} + +// SendMessageResponse represents the response from sending a message +type SendMessageResponse struct { + MessagingProduct string `json:"messaging_product"` + Contacts []struct { + Input string `json:"input"` + WaID string `json:"wa_id"` + } `json:"contacts"` + Messages []struct { + ID string `json:"id"` + } `json:"messages"` +} + +// MediaUploadResponse represents the response from uploading media +type MediaUploadResponse struct { + ID string `json:"id"` // Media ID to use in messages +} + +// MediaURLResponse represents the response when getting media URL +type MediaURLResponse struct { + URL string `json:"url"` // CDN URL to download media + MimeType string `json:"mime_type"` + SHA256 string `json:"sha256"` + FileSize int64 `json:"file_size"` + ID string `json:"id"` + MessagingProduct string `json:"messaging_product"` +} + +// ErrorResponse represents an error from the Business API +type ErrorResponse struct { + Error struct { + Message string `json:"message"` + Type string `json:"type"` + Code int `json:"code"` + ErrorSubcode int `json:"error_subcode,omitempty"` + FBTraceID string `json:"fbtrace_id,omitempty"` + } `json:"error"` +} + +// WebhookPayload represents the incoming webhook from WhatsApp Business API +type WebhookPayload struct { + Object string `json:"object"` // "whatsapp_business_account" + Entry []WebhookEntry `json:"entry"` +} + +// WebhookEntry represents an entry in the webhook +type WebhookEntry struct { + ID string `json:"id"` // WhatsApp Business Account ID + Changes []WebhookChange `json:"changes"` +} + +// WebhookChange represents a change notification +type WebhookChange struct { + Value WebhookValue `json:"value"` + Field string `json:"field"` // "messages" +} + +// WebhookValue contains the actual webhook data +type WebhookValue struct { + MessagingProduct string `json:"messaging_product"` + Metadata WebhookMetadata `json:"metadata"` + Contacts []WebhookContact `json:"contacts,omitempty"` + Messages []WebhookMessage `json:"messages,omitempty"` + Statuses []WebhookStatus `json:"statuses,omitempty"` +} + +// WebhookMetadata contains metadata about the phone number +type WebhookMetadata struct { + DisplayPhoneNumber string `json:"display_phone_number"` + PhoneNumberID string `json:"phone_number_id"` +} + +// WebhookContact represents a contact in the webhook +type WebhookContact struct { + Profile WebhookProfile `json:"profile"` + WaID string `json:"wa_id"` +} + +// WebhookProfile contains profile information +type WebhookProfile struct { + Name string `json:"name"` +} + +// WebhookMessage represents a message in the webhook +type WebhookMessage struct { + From string `json:"from"` // Sender phone number + ID string `json:"id"` // Message ID + Timestamp string `json:"timestamp"` // Unix timestamp as string + Type string `json:"type"` // "text", "image", "video", "document", etc. + Text *WebhookText `json:"text,omitempty"` + Image *WebhookMediaMessage `json:"image,omitempty"` + Video *WebhookMediaMessage `json:"video,omitempty"` + Document *WebhookDocumentMessage `json:"document,omitempty"` + Context *WebhookContext `json:"context,omitempty"` // Reply context +} + +// WebhookText represents a text message +type WebhookText struct { + Body string `json:"body"` +} + +// WebhookMediaMessage represents a media message (image/video) +type WebhookMediaMessage struct { + ID string `json:"id"` // Media ID + MimeType string `json:"mime_type"` + SHA256 string `json:"sha256"` + Caption string `json:"caption,omitempty"` +} + +// WebhookDocumentMessage represents a document message +type WebhookDocumentMessage struct { + ID string `json:"id"` // Media ID + MimeType string `json:"mime_type"` + SHA256 string `json:"sha256"` + Filename string `json:"filename,omitempty"` + Caption string `json:"caption,omitempty"` +} + +// WebhookContext represents reply context +type WebhookContext struct { + From string `json:"from"` + ID string `json:"id"` // Message ID being replied to + MessageID string `json:"message_id,omitempty"` +} + +// WebhookStatus represents a message status update +type WebhookStatus struct { + ID string `json:"id"` // Message ID + Status string `json:"status"` // "sent", "delivered", "read", "failed" + Timestamp string `json:"timestamp"` // Unix timestamp as string + RecipientID string `json:"recipient_id"` + Conversation *WebhookConversation `json:"conversation,omitempty"` + Pricing *WebhookPricing `json:"pricing,omitempty"` + Errors []WebhookError `json:"errors,omitempty"` +} + +// WebhookConversation contains conversation details +type WebhookConversation struct { + ID string `json:"id"` + ExpirationTimestamp string `json:"expiration_timestamp,omitempty"` + Origin WebhookOrigin `json:"origin"` +} + +// WebhookOrigin contains conversation origin +type WebhookOrigin struct { + Type string `json:"type"` +} + +// WebhookPricing contains pricing information +type WebhookPricing struct { + Billable bool `json:"billable"` + PricingModel string `json:"pricing_model"` + Category string `json:"category"` +} + +// WebhookError represents an error in status update +type WebhookError struct { + Code int `json:"code"` + Title string `json:"title"` +} diff --git a/internal/whatsapp/client.go b/internal/whatsapp/client.go deleted file mode 100644 index 261f58e..0000000 --- a/internal/whatsapp/client.go +++ /dev/null @@ -1,767 +0,0 @@ -package whatsapp - -import ( - "context" - "crypto/sha256" - "encoding/base64" - "encoding/hex" - "fmt" - "os" - "path/filepath" - "sync" - "time" - - "git.warky.dev/wdevs/whatshooked/internal/config" - "git.warky.dev/wdevs/whatshooked/internal/events" - "git.warky.dev/wdevs/whatshooked/internal/logging" - - qrterminal "github.com/mdp/qrterminal/v3" - "go.mau.fi/whatsmeow" - "go.mau.fi/whatsmeow/proto/waE2E" - "go.mau.fi/whatsmeow/store/sqlstore" - "go.mau.fi/whatsmeow/types" - waEvents "go.mau.fi/whatsmeow/types/events" - waLog "go.mau.fi/whatsmeow/util/log" - "google.golang.org/protobuf/proto" - - _ "github.com/mattn/go-sqlite3" -) - -// Manager manages multiple WhatsApp client connections -type Manager struct { - clients map[string]*Client - mu sync.RWMutex - eventBus *events.EventBus - mediaConfig config.MediaConfig - config *config.Config - configPath string - onConfigUpdate func(*config.Config) error -} - -// Client represents a single WhatsApp connection -type Client struct { - ID string - PhoneNumber string - Client *whatsmeow.Client - Container *sqlstore.Container - keepAliveCancel context.CancelFunc -} - -// NewManager creates a new WhatsApp manager -func NewManager(eventBus *events.EventBus, mediaConfig config.MediaConfig, cfg *config.Config, configPath string, onConfigUpdate func(*config.Config) error) *Manager { - return &Manager{ - clients: make(map[string]*Client), - eventBus: eventBus, - mediaConfig: mediaConfig, - config: cfg, - configPath: configPath, - onConfigUpdate: onConfigUpdate, - } -} - -// Connect establishes a connection to a WhatsApp account -func (m *Manager) Connect(ctx context.Context, cfg config.WhatsAppConfig) error { - m.mu.Lock() - defer m.mu.Unlock() - - if _, exists := m.clients[cfg.ID]; exists { - return fmt.Errorf("client %s already connected", cfg.ID) - } - - // Ensure session directory exists - if err := os.MkdirAll(cfg.SessionPath, 0700); err != nil { - return fmt.Errorf("failed to create session directory: %w", err) - } - - // Create database container for session storage - dbPath := filepath.Join(cfg.SessionPath, "session.db") - dbLog := waLog.Stdout("Database", "ERROR", true) - container, err := sqlstore.New(ctx, "sqlite3", "file:"+dbPath+"?_foreign_keys=on", dbLog) - if err != nil { - return fmt.Errorf("failed to create database container: %w", err) - } - - // Get device store - deviceStore, err := container.GetFirstDevice(ctx) - if err != nil { - return fmt.Errorf("failed to get device: %w", err) - } - - // Set custom client information - //if deviceStore.ID == nil { - // Only set for new devices - deviceStore.Platform = "WhatsHooked" - deviceStore.BusinessName = "git.warky.dev/wdevs/whatshooked" - - //} - - // Create client - clientLog := waLog.Stdout("Client", "ERROR", true) - client := whatsmeow.NewClient(deviceStore, clientLog) - - // Register event handler - client.AddEventHandler(func(evt interface{}) { - m.handleEvent(cfg.ID, evt) - }) - - // Connect - if client.Store.ID == nil { - // New device, need to pair - qrChan, _ := client.GetQRChannel(ctx) - if err := client.Connect(); err != nil { - m.eventBus.Publish(events.WhatsAppPairFailedEvent(ctx, cfg.ID, err)) - return fmt.Errorf("failed to connect: %w", err) - } - - // Wait for QR code - for evt := range qrChan { - switch evt.Event { - case "code": - logging.Info("QR code received for pairing", "account_id", cfg.ID) - - // Always display QR code in terminal - fmt.Println("\n========================================") - fmt.Printf("WhatsApp QR Code for account: %s\n", cfg.ID) - fmt.Printf("Phone: %s\n", cfg.PhoneNumber) - fmt.Println("========================================") - fmt.Println("Scan this QR code with WhatsApp on your phone:") - qrterminal.GenerateHalfBlock(evt.Code, qrterminal.L, os.Stdout) - fmt.Println("========================================") - - // Publish QR code event - m.eventBus.Publish(events.WhatsAppQRCodeEvent(ctx, cfg.ID, evt.Code)) - - case "success": - logging.Info("Pairing successful", "account_id", cfg.ID, "phone", cfg.PhoneNumber) - m.eventBus.Publish(events.WhatsAppPairSuccessEvent(ctx, cfg.ID)) - - case "timeout": - logging.Warn("QR code timeout", "account_id", cfg.ID) - m.eventBus.Publish(events.WhatsAppQRTimeoutEvent(ctx, cfg.ID)) - - case "error": - logging.Error("QR code error", "account_id", cfg.ID, "error", evt.Error) - m.eventBus.Publish(events.WhatsAppQRErrorEvent(ctx, cfg.ID, fmt.Errorf("%v", evt.Error))) - - default: - logging.Info("Pairing event", "account_id", cfg.ID, "event", evt.Event) - m.eventBus.Publish(events.WhatsAppPairEventGeneric(ctx, cfg.ID, evt.Event, map[string]any{ - "code": evt.Code, - })) - } - } - } else { - // Already paired, just connect - if err := client.Connect(); err != nil { - return fmt.Errorf("failed to connect: %w", err) - } - } - - if deviceStore.PushName == "" { - deviceStore.PushName = fmt.Sprintf("WhatsHooked %s", cfg.PhoneNumber) - if err := deviceStore.Save(ctx); err != nil { - logging.Error("failed to save device store %s", cfg.ID) - } - } - - waClient := &Client{ - ID: cfg.ID, - PhoneNumber: cfg.PhoneNumber, - Client: client, - Container: container, - } - - m.clients[cfg.ID] = waClient - - if client.IsConnected() { - err := client.SendPresence(ctx, types.PresenceAvailable) - if err != nil { - logging.Warn("Failed to send presence", "account_id", cfg.ID, "error", err) - } else { - logging.Debug("Sent presence update", "account_id", cfg.ID) - } - } - - // Start keep-alive routine - m.startKeepAlive(waClient) - - logging.Info("WhatsApp client connected", "account_id", cfg.ID, "phone", cfg.PhoneNumber) - return nil -} - -// Disconnect disconnects a WhatsApp client -func (m *Manager) Disconnect(id string) error { - m.mu.Lock() - defer m.mu.Unlock() - - client, exists := m.clients[id] - if !exists { - return fmt.Errorf("client %s not found", id) - } - - // Stop keep-alive - if client.keepAliveCancel != nil { - client.keepAliveCancel() - } - - client.Client.Disconnect() - delete(m.clients, id) - - logging.Info("WhatsApp client disconnected", "account_id", id) - return nil -} - -// DisconnectAll disconnects all WhatsApp clients -func (m *Manager) DisconnectAll() { - m.mu.Lock() - defer m.mu.Unlock() - - for id, client := range m.clients { - // Stop keep-alive - if client.keepAliveCancel != nil { - client.keepAliveCancel() - } - client.Client.Disconnect() - logging.Info("WhatsApp client disconnected", "account_id", id) - } - m.clients = make(map[string]*Client) -} - -// SendTextMessage sends a text message from a specific account -func (m *Manager) SendTextMessage(ctx context.Context, accountID string, jid types.JID, text string) error { - if ctx == nil { - ctx = context.Background() - } - - m.mu.RLock() - client, exists := m.clients[accountID] - m.mu.RUnlock() - - if !exists { - err := fmt.Errorf("client %s not found", accountID) - m.eventBus.Publish(events.MessageFailedEvent(ctx, accountID, jid.String(), text, err)) - return err - } - - msg := &waE2E.Message{ - Conversation: proto.String(text), - } - - resp, err := client.Client.SendMessage(ctx, jid, msg) - if err != nil { - m.eventBus.Publish(events.MessageFailedEvent(ctx, accountID, jid.String(), text, err)) - return fmt.Errorf("failed to send message: %w", err) - } - - logging.Debug("Message sent", "account_id", accountID, "to", jid.String()) - m.eventBus.Publish(events.MessageSentEvent(ctx, accountID, resp.ID, jid.String(), text)) - return nil -} - -// SendImage sends an image message from a specific account -func (m *Manager) SendImage(ctx context.Context, accountID string, jid types.JID, imageData []byte, mimeType string, caption string) error { - if ctx == nil { - ctx = context.Background() - } - - m.mu.RLock() - client, exists := m.clients[accountID] - m.mu.RUnlock() - - if !exists { - err := fmt.Errorf("client %s not found", accountID) - m.eventBus.Publish(events.MessageFailedEvent(ctx, accountID, jid.String(), caption, err)) - return err - } - - // Upload the image - uploaded, err := client.Client.Upload(ctx, imageData, whatsmeow.MediaImage) - if err != nil { - m.eventBus.Publish(events.MessageFailedEvent(ctx, accountID, jid.String(), caption, err)) - return fmt.Errorf("failed to upload image: %w", err) - } - - // Create image message - msg := &waE2E.Message{ - ImageMessage: &waE2E.ImageMessage{ - URL: proto.String(uploaded.URL), - DirectPath: proto.String(uploaded.DirectPath), - MediaKey: uploaded.MediaKey, - Mimetype: proto.String(mimeType), - FileEncSHA256: uploaded.FileEncSHA256, - FileSHA256: uploaded.FileSHA256, - FileLength: proto.Uint64(uint64(len(imageData))), - }, - } - - // Add caption if provided - if caption != "" { - msg.ImageMessage.Caption = proto.String(caption) - } - - // Send the message - resp, err := client.Client.SendMessage(ctx, jid, msg) - if err != nil { - m.eventBus.Publish(events.MessageFailedEvent(ctx, accountID, jid.String(), caption, err)) - return fmt.Errorf("failed to send image: %w", err) - } - - logging.Debug("Image sent", "account_id", accountID, "to", jid.String()) - m.eventBus.Publish(events.MessageSentEvent(ctx, accountID, resp.ID, jid.String(), caption)) - return nil -} - -// SendVideo sends a video message from a specific account -func (m *Manager) SendVideo(ctx context.Context, accountID string, jid types.JID, videoData []byte, mimeType string, caption string) error { - if ctx == nil { - ctx = context.Background() - } - - m.mu.RLock() - client, exists := m.clients[accountID] - m.mu.RUnlock() - - if !exists { - err := fmt.Errorf("client %s not found", accountID) - m.eventBus.Publish(events.MessageFailedEvent(ctx, accountID, jid.String(), caption, err)) - return err - } - - // Upload the video - uploaded, err := client.Client.Upload(ctx, videoData, whatsmeow.MediaVideo) - if err != nil { - m.eventBus.Publish(events.MessageFailedEvent(ctx, accountID, jid.String(), caption, err)) - return fmt.Errorf("failed to upload video: %w", err) - } - - // Create video message - msg := &waE2E.Message{ - VideoMessage: &waE2E.VideoMessage{ - URL: proto.String(uploaded.URL), - DirectPath: proto.String(uploaded.DirectPath), - MediaKey: uploaded.MediaKey, - Mimetype: proto.String(mimeType), - FileEncSHA256: uploaded.FileEncSHA256, - FileSHA256: uploaded.FileSHA256, - FileLength: proto.Uint64(uint64(len(videoData))), - }, - } - - // Add caption if provided - if caption != "" { - msg.VideoMessage.Caption = proto.String(caption) - } - - // Send the message - resp, err := client.Client.SendMessage(ctx, jid, msg) - if err != nil { - m.eventBus.Publish(events.MessageFailedEvent(ctx, accountID, jid.String(), caption, err)) - return fmt.Errorf("failed to send video: %w", err) - } - - logging.Debug("Video sent", "account_id", accountID, "to", jid.String()) - m.eventBus.Publish(events.MessageSentEvent(ctx, accountID, resp.ID, jid.String(), caption)) - return nil -} - -// SendDocument sends a document message from a specific account -func (m *Manager) SendDocument(ctx context.Context, accountID string, jid types.JID, documentData []byte, mimeType string, filename string, caption string) error { - if ctx == nil { - ctx = context.Background() - } - - m.mu.RLock() - client, exists := m.clients[accountID] - m.mu.RUnlock() - - if !exists { - err := fmt.Errorf("client %s not found", accountID) - m.eventBus.Publish(events.MessageFailedEvent(ctx, accountID, jid.String(), caption, err)) - return err - } - - // Upload the document - uploaded, err := client.Client.Upload(ctx, documentData, whatsmeow.MediaDocument) - if err != nil { - m.eventBus.Publish(events.MessageFailedEvent(ctx, accountID, jid.String(), caption, err)) - return fmt.Errorf("failed to upload document: %w", err) - } - - // Create document message - msg := &waE2E.Message{ - DocumentMessage: &waE2E.DocumentMessage{ - URL: proto.String(uploaded.URL), - DirectPath: proto.String(uploaded.DirectPath), - MediaKey: uploaded.MediaKey, - Mimetype: proto.String(mimeType), - FileEncSHA256: uploaded.FileEncSHA256, - FileSHA256: uploaded.FileSHA256, - FileLength: proto.Uint64(uint64(len(documentData))), - FileName: proto.String(filename), - }, - } - - // Add caption if provided - if caption != "" { - msg.DocumentMessage.Caption = proto.String(caption) - } - - // Send the message - resp, err := client.Client.SendMessage(ctx, jid, msg) - if err != nil { - m.eventBus.Publish(events.MessageFailedEvent(ctx, accountID, jid.String(), caption, err)) - return fmt.Errorf("failed to send document: %w", err) - } - - logging.Debug("Document sent", "account_id", accountID, "to", jid.String(), "filename", filename) - m.eventBus.Publish(events.MessageSentEvent(ctx, accountID, resp.ID, jid.String(), caption)) - return nil -} - -// GetClient returns a client by ID -func (m *Manager) GetClient(id string) (*Client, bool) { - m.mu.RLock() - defer m.mu.RUnlock() - client, exists := m.clients[id] - return client, exists -} - -// handleEvent processes WhatsApp events -func (m *Manager) handleEvent(accountID string, evt interface{}) { - ctx := context.Background() - - switch v := evt.(type) { - case *waEvents.Message: - logging.Debug("Message received", "account_id", accountID, "from", v.Info.Sender.String()) - - // Get the client for downloading media - m.mu.RLock() - client, exists := m.clients[accountID] - m.mu.RUnlock() - - if !exists { - logging.Error("Client not found for message event", "account_id", accountID) - return - } - - // Extract message content based on type - var text string - var messageType string = "text" - var mimeType string - var filename string - var mediaBase64 string - var mediaURL string - - // Handle text messages - if v.Message.Conversation != nil { - text = *v.Message.Conversation - messageType = "text" - } else if v.Message.ExtendedTextMessage != nil && v.Message.ExtendedTextMessage.Text != nil { - text = *v.Message.ExtendedTextMessage.Text - messageType = "text" - } - - // Handle image messages - if v.Message.ImageMessage != nil { - img := v.Message.ImageMessage - messageType = "image" - mimeType = img.GetMimetype() - - // Use filename from caption or default - if img.Caption != nil { - text = *img.Caption - } - - // Download image - data, err := client.Client.Download(ctx, img) - if err != nil { - logging.Error("Failed to download image", "account_id", accountID, "error", err) - } else { - filename, mediaURL = m.processMediaData(accountID, v.Info.ID, data, mimeType, &mediaBase64) - } - } - - // Handle video messages - if v.Message.VideoMessage != nil { - vid := v.Message.VideoMessage - messageType = "video" - mimeType = vid.GetMimetype() - - // Use filename from caption or default - if vid.Caption != nil { - text = *vid.Caption - } - - // Download video - data, err := client.Client.Download(ctx, vid) - if err != nil { - logging.Error("Failed to download video", "account_id", accountID, "error", err) - } else { - filename, mediaURL = m.processMediaData(accountID, v.Info.ID, data, mimeType, &mediaBase64) - } - } - - // Handle document messages - if v.Message.DocumentMessage != nil { - doc := v.Message.DocumentMessage - messageType = "document" - mimeType = doc.GetMimetype() - - // Use provided filename or generate one - if doc.FileName != nil { - filename = *doc.FileName - } - - // Use caption as text if provided - if doc.Caption != nil { - text = *doc.Caption - } - - // Download document - data, err := client.Client.Download(ctx, doc) - if err != nil { - logging.Error("Failed to download document", "account_id", accountID, "error", err) - } else { - filename, mediaURL = m.processMediaData(accountID, v.Info.ID, data, mimeType, &mediaBase64) - } - } - - // Publish message received event - m.eventBus.Publish(events.MessageReceivedEvent( - ctx, - accountID, - v.Info.ID, - v.Info.Sender.String(), - v.Info.Chat.String(), - text, - v.Info.Timestamp, - v.Info.IsGroup, - "", // group name - TODO: extract from message - "", // sender name - TODO: extract from message - messageType, - mimeType, - filename, - mediaBase64, - mediaURL, - )) - - case *waEvents.Connected: - logging.Info("WhatsApp connected", "account_id", accountID) - - // Get phone number and client for account - m.mu.RLock() - client, exists := m.clients[accountID] - m.mu.RUnlock() - - phoneNumber := "" - if exists { - // Get the actual phone number from WhatsApp - if client.Client.Store.ID != nil { - actualPhone := client.Client.Store.ID.User - phoneNumber = "+" + actualPhone - - // Update phone number in client and config if it's different - if client.PhoneNumber != phoneNumber { - client.PhoneNumber = phoneNumber - logging.Info("Updated phone number from WhatsApp", "account_id", accountID, "phone", phoneNumber) - - // Update config - m.updateConfigPhoneNumber(accountID, phoneNumber) - } - } else if client.PhoneNumber != "" { - phoneNumber = client.PhoneNumber - } - } - - m.eventBus.Publish(events.WhatsAppConnectedEvent(ctx, accountID, phoneNumber)) - - case *waEvents.Disconnected: - logging.Warn("WhatsApp disconnected", "account_id", accountID) - m.eventBus.Publish(events.WhatsAppDisconnectedEvent(ctx, accountID, "connection lost")) - - case *waEvents.Receipt: - // Handle delivery and read receipts - if v.Type == types.ReceiptTypeDelivered { - for _, messageID := range v.MessageIDs { - logging.Debug("Message delivered", "account_id", accountID, "message_id", messageID, "from", v.Sender.String()) - m.eventBus.Publish(events.MessageDeliveredEvent(ctx, accountID, messageID, v.Sender.String(), v.Timestamp)) - } - } else if v.Type == types.ReceiptTypeRead { - for _, messageID := range v.MessageIDs { - logging.Debug("Message read", "account_id", accountID, "message_id", messageID, "from", v.Sender.String()) - m.eventBus.Publish(events.MessageReadEvent(ctx, accountID, messageID, v.Sender.String(), v.Timestamp)) - } - } - } -} - -// startKeepAlive starts a goroutine that sends presence updates to keep the connection alive -func (m *Manager) startKeepAlive(client *Client) { - ctx, cancel := context.WithCancel(context.Background()) - client.keepAliveCancel = cancel - - go func() { - ticker := time.NewTicker(60 * time.Second) // Send presence every 60 seconds - defer ticker.Stop() - - for { - select { - case <-ctx.Done(): - logging.Debug("Keep-alive stopped", "account_id", client.ID) - return - case <-ticker.C: - // Send presence as "available" - if client.Client.IsConnected() { - err := client.Client.SendPresence(ctx, types.PresenceAvailable) - if err != nil { - logging.Warn("Failed to send presence", "account_id", client.ID, "error", err) - } else { - logging.Debug("Sent presence update", "account_id", client.ID) - } - } - } - } - }() - - logging.Info("Keep-alive started", "account_id", client.ID) -} - -// updateConfigPhoneNumber updates the phone number for an account in the config and saves it -func (m *Manager) updateConfigPhoneNumber(accountID, phoneNumber string) { - if m.config == nil || m.onConfigUpdate == nil { - return - } - - // Find and update the account in the config - for i := range m.config.WhatsApp { - if m.config.WhatsApp[i].ID == accountID { - m.config.WhatsApp[i].PhoneNumber = phoneNumber - - // Save the updated config - if err := m.onConfigUpdate(m.config); err != nil { - logging.Error("Failed to save updated config", "account_id", accountID, "error", err) - } else { - logging.Info("Config updated with phone number", "account_id", accountID, "phone", phoneNumber) - } - break - } - } -} - -// processMediaData processes media based on the configured mode -// Returns filename and mediaURL, and optionally sets mediaBase64 -func (m *Manager) processMediaData(accountID, messageID string, data []byte, mimeType string, mediaBase64 *string) (string, string) { - mode := m.mediaConfig.Mode - var filename string - var mediaURL string - - // Generate filename - ext := getExtensionFromMimeType(mimeType) - hash := sha256.Sum256(data) - hashStr := hex.EncodeToString(hash[:8]) - filename = fmt.Sprintf("%s_%s%s", messageID, hashStr, ext) - - // Handle base64 mode - if mode == "base64" || mode == "both" { - *mediaBase64 = base64.StdEncoding.EncodeToString(data) - } - - // Handle link mode - if mode == "link" || mode == "both" { - // Save file to disk - filePath, err := m.saveMediaFile(accountID, messageID, data, mimeType) - if err != nil { - logging.Error("Failed to save media file", "account_id", accountID, "message_id", messageID, "error", err) - } else { - // Extract just the filename from the full path - filename = filepath.Base(filePath) - mediaURL = m.generateMediaURL(accountID, messageID, filename) - } - } - - return filename, mediaURL -} - -// saveMediaFile saves media data to disk and returns the file path -func (m *Manager) saveMediaFile(accountID, messageID string, data []byte, mimeType string) (string, error) { - // Create account-specific media directory - mediaDir := filepath.Join(m.mediaConfig.DataPath, accountID) - if err := os.MkdirAll(mediaDir, 0755); err != nil { - return "", fmt.Errorf("failed to create media directory: %w", err) - } - - // Generate unique filename using message ID and hash - hash := sha256.Sum256(data) - hashStr := hex.EncodeToString(hash[:8]) // Use first 8 bytes of hash - ext := getExtensionFromMimeType(mimeType) - filename := fmt.Sprintf("%s_%s%s", messageID, hashStr, ext) - - // Full path to file - filePath := filepath.Join(mediaDir, filename) - - // Write file - if err := os.WriteFile(filePath, data, 0644); err != nil { - return "", fmt.Errorf("failed to write media file: %w", err) - } - - return filePath, nil -} - -// generateMediaURL generates a URL for accessing stored media -func (m *Manager) generateMediaURL(accountID, messageID, filename string) string { - baseURL := m.mediaConfig.BaseURL - if baseURL == "" { - baseURL = "http://localhost:8080" // default - } - return fmt.Sprintf("%s/api/media/%s/%s", baseURL, accountID, filename) -} - -// getExtensionFromMimeType returns the file extension for a given MIME type -func getExtensionFromMimeType(mimeType string) string { - extensions := map[string]string{ - // Images - "image/jpeg": ".jpg", - "image/jpg": ".jpg", - "image/png": ".png", - "image/gif": ".gif", - "image/webp": ".webp", - "image/bmp": ".bmp", - "image/svg+xml": ".svg", - - // Videos - "video/mp4": ".mp4", - "video/mpeg": ".mpeg", - "video/quicktime": ".mov", - "video/x-msvideo": ".avi", - "video/webm": ".webm", - "video/3gpp": ".3gp", - - // Documents - "application/pdf": ".pdf", - "application/msword": ".doc", - "application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx", - "application/vnd.ms-excel": ".xls", - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx", - "application/vnd.ms-powerpoint": ".ppt", - "application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx", - "text/plain": ".txt", - "text/html": ".html", - "application/zip": ".zip", - "application/x-rar-compressed": ".rar", - "application/x-7z-compressed": ".7z", - "application/json": ".json", - "application/xml": ".xml", - - // Audio - "audio/mpeg": ".mp3", - "audio/ogg": ".ogg", - "audio/wav": ".wav", - "audio/aac": ".aac", - "audio/x-m4a": ".m4a", - } - - if ext, ok := extensions[mimeType]; ok { - return ext - } - return "" // No extension if mime type is unknown -} diff --git a/internal/whatsapp/interface.go b/internal/whatsapp/interface.go new file mode 100644 index 0000000..b45b6a7 --- /dev/null +++ b/internal/whatsapp/interface.go @@ -0,0 +1,34 @@ +package whatsapp + +import ( + "context" + + "go.mau.fi/whatsmeow/types" +) + +// ClientType identifies the type of WhatsApp client +type ClientType string + +const ( + ClientTypeWhatsmeow ClientType = "whatsmeow" + ClientTypeBusinessAPI ClientType = "business-api" +) + +// Client represents any WhatsApp client implementation (whatsmeow or Business API) +type Client interface { + // Connection Management + Connect(ctx context.Context) error + Disconnect() error + IsConnected() bool + + // Account Information + GetID() string + GetPhoneNumber() string + GetType() string + + // Message Sending + SendTextMessage(ctx context.Context, jid types.JID, text string) (messageID string, err error) + SendImage(ctx context.Context, jid types.JID, imageData []byte, mimeType string, caption string) (messageID string, err error) + SendVideo(ctx context.Context, jid types.JID, videoData []byte, mimeType string, caption string) (messageID string, err error) + SendDocument(ctx context.Context, jid types.JID, documentData []byte, mimeType string, filename string, caption string) (messageID string, err error) +} diff --git a/internal/whatsapp/manager.go b/internal/whatsapp/manager.go new file mode 100644 index 0000000..8b06bd3 --- /dev/null +++ b/internal/whatsapp/manager.go @@ -0,0 +1,171 @@ +package whatsapp + +import ( + "context" + "fmt" + "sync" + + "git.warky.dev/wdevs/whatshooked/internal/config" + "git.warky.dev/wdevs/whatshooked/internal/events" + "git.warky.dev/wdevs/whatshooked/internal/logging" + "git.warky.dev/wdevs/whatshooked/internal/whatsapp/businessapi" + "git.warky.dev/wdevs/whatshooked/internal/whatsapp/whatsmeow" + + "go.mau.fi/whatsmeow/types" +) + +// Manager manages multiple WhatsApp client connections +type Manager struct { + clients map[string]Client + mu sync.RWMutex + eventBus *events.EventBus + mediaConfig config.MediaConfig + config *config.Config + configPath string + onConfigUpdate func(*config.Config) error +} + +// NewManager creates a new WhatsApp manager +func NewManager(eventBus *events.EventBus, mediaConfig config.MediaConfig, cfg *config.Config, configPath string, onConfigUpdate func(*config.Config) error) *Manager { + return &Manager{ + clients: make(map[string]Client), + eventBus: eventBus, + mediaConfig: mediaConfig, + config: cfg, + configPath: configPath, + onConfigUpdate: onConfigUpdate, + } +} + +// Connect establishes a connection to a WhatsApp account using the appropriate client type +func (m *Manager) Connect(ctx context.Context, cfg config.WhatsAppConfig) error { + m.mu.Lock() + defer m.mu.Unlock() + + if _, exists := m.clients[cfg.ID]; exists { + return fmt.Errorf("client %s already connected", cfg.ID) + } + + var client Client + var err error + + // Factory pattern based on type + switch cfg.Type { + case "business-api": + client, err = businessapi.NewClient(cfg, m.eventBus, m.mediaConfig) + case "whatsmeow", "": + client, err = whatsmeow.NewClient(cfg, m.eventBus, m.mediaConfig) + default: + return fmt.Errorf("unknown client type: %s", cfg.Type) + } + + if err != nil { + return fmt.Errorf("failed to create client: %w", err) + } + + if err := client.Connect(ctx); err != nil { + return fmt.Errorf("failed to connect: %w", err) + } + + m.clients[cfg.ID] = client + logging.Info("Client connected", "account_id", cfg.ID, "type", client.GetType()) + return nil +} + +// Disconnect disconnects a WhatsApp client +func (m *Manager) Disconnect(id string) error { + m.mu.Lock() + defer m.mu.Unlock() + + client, exists := m.clients[id] + if !exists { + return fmt.Errorf("client %s not found", id) + } + + if err := client.Disconnect(); err != nil { + return fmt.Errorf("failed to disconnect: %w", err) + } + + delete(m.clients, id) + logging.Info("Client disconnected", "account_id", id) + return nil +} + +// DisconnectAll disconnects all WhatsApp clients +func (m *Manager) DisconnectAll() { + m.mu.Lock() + defer m.mu.Unlock() + + for id, client := range m.clients { + if err := client.Disconnect(); err != nil { + logging.Error("Failed to disconnect client", "account_id", id, "error", err) + } else { + logging.Info("Client disconnected", "account_id", id) + } + } + m.clients = make(map[string]Client) +} + +// SendTextMessage sends a text message from a specific account +func (m *Manager) SendTextMessage(ctx context.Context, accountID string, jid types.JID, text string) error { + m.mu.RLock() + client, exists := m.clients[accountID] + m.mu.RUnlock() + + if !exists { + return fmt.Errorf("client %s not found", accountID) + } + + _, err := client.SendTextMessage(ctx, jid, text) + return err +} + +// SendImage sends an image message from a specific account +func (m *Manager) SendImage(ctx context.Context, accountID string, jid types.JID, imageData []byte, mimeType string, caption string) error { + m.mu.RLock() + client, exists := m.clients[accountID] + m.mu.RUnlock() + + if !exists { + return fmt.Errorf("client %s not found", accountID) + } + + _, err := client.SendImage(ctx, jid, imageData, mimeType, caption) + return err +} + +// SendVideo sends a video message from a specific account +func (m *Manager) SendVideo(ctx context.Context, accountID string, jid types.JID, videoData []byte, mimeType string, caption string) error { + m.mu.RLock() + client, exists := m.clients[accountID] + m.mu.RUnlock() + + if !exists { + return fmt.Errorf("client %s not found", accountID) + } + + _, err := client.SendVideo(ctx, jid, videoData, mimeType, caption) + return err +} + +// SendDocument sends a document message from a specific account +func (m *Manager) SendDocument(ctx context.Context, accountID string, jid types.JID, documentData []byte, mimeType string, filename string, caption string) error { + m.mu.RLock() + client, exists := m.clients[accountID] + m.mu.RUnlock() + + if !exists { + return fmt.Errorf("client %s not found", accountID) + } + + _, err := client.SendDocument(ctx, jid, documentData, mimeType, filename, caption) + return err +} + +// GetClient returns a client by ID +func (m *Manager) GetClient(id string) (Client, bool) { + m.mu.RLock() + defer m.mu.RUnlock() + client, exists := m.clients[id] + return client, exists +} diff --git a/internal/whatsapp/whatsmeow/client.go b/internal/whatsapp/whatsmeow/client.go new file mode 100644 index 0000000..8a564d3 --- /dev/null +++ b/internal/whatsapp/whatsmeow/client.go @@ -0,0 +1,678 @@ +package whatsmeow + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "time" + + "git.warky.dev/wdevs/whatshooked/internal/config" + "git.warky.dev/wdevs/whatshooked/internal/events" + "git.warky.dev/wdevs/whatshooked/internal/logging" + + qrterminal "github.com/mdp/qrterminal/v3" + "go.mau.fi/whatsmeow" + "go.mau.fi/whatsmeow/proto/waE2E" + "go.mau.fi/whatsmeow/store/sqlstore" + "go.mau.fi/whatsmeow/types" + waEvents "go.mau.fi/whatsmeow/types/events" + waLog "go.mau.fi/whatsmeow/util/log" + "google.golang.org/protobuf/proto" + + _ "github.com/mattn/go-sqlite3" +) + +// Client represents a WhatsApp connection using whatsmeow +type Client struct { + id string + phoneNumber string + sessionPath string + client *whatsmeow.Client + container *sqlstore.Container + eventBus *events.EventBus + mediaConfig config.MediaConfig + showQR bool + keepAliveCancel context.CancelFunc +} + +// NewClient creates a new whatsmeow client +func NewClient(cfg config.WhatsAppConfig, eventBus *events.EventBus, mediaConfig config.MediaConfig) (*Client, error) { + if cfg.Type != "whatsmeow" && cfg.Type != "" { + return nil, fmt.Errorf("invalid client type for whatsmeow: %s", cfg.Type) + } + + sessionPath := cfg.SessionPath + if sessionPath == "" { + sessionPath = fmt.Sprintf("./sessions/%s", cfg.ID) + } + + return &Client{ + id: cfg.ID, + phoneNumber: cfg.PhoneNumber, + sessionPath: sessionPath, + eventBus: eventBus, + mediaConfig: mediaConfig, + showQR: cfg.ShowQR, + }, nil +} + +// Connect establishes a connection to WhatsApp +func (c *Client) Connect(ctx context.Context) error { + // Ensure session directory exists + if err := os.MkdirAll(c.sessionPath, 0700); err != nil { + return fmt.Errorf("failed to create session directory: %w", err) + } + + // Create database container for session storage + dbPath := filepath.Join(c.sessionPath, "session.db") + dbLog := waLog.Stdout("Database", "ERROR", true) + container, err := sqlstore.New(ctx, "sqlite3", "file:"+dbPath+"?_foreign_keys=on", dbLog) + if err != nil { + return fmt.Errorf("failed to create database container: %w", err) + } + c.container = container + + // Get device store + deviceStore, err := container.GetFirstDevice(ctx) + if err != nil { + return fmt.Errorf("failed to get device: %w", err) + } + + // Set custom client information + deviceStore.Platform = "WhatsHooked" + deviceStore.BusinessName = "git.warky.dev/wdevs/whatshooked" + + // Create client + clientLog := waLog.Stdout("Client", "ERROR", true) + client := whatsmeow.NewClient(deviceStore, clientLog) + c.client = client + + // Register event handler + client.AddEventHandler(func(evt interface{}) { + c.handleEvent(evt) + }) + + // Connect + if client.Store.ID == nil { + // New device, need to pair + qrChan, _ := client.GetQRChannel(ctx) + if err := client.Connect(); err != nil { + c.eventBus.Publish(events.WhatsAppPairFailedEvent(ctx, c.id, err)) + return fmt.Errorf("failed to connect: %w", err) + } + + // Wait for QR code + for evt := range qrChan { + switch evt.Event { + case "code": + logging.Info("QR code received for pairing", "account_id", c.id) + + // Display QR code in terminal + fmt.Println("\n========================================") + fmt.Printf("WhatsApp QR Code for account: %s\n", c.id) + fmt.Printf("Phone: %s\n", c.phoneNumber) + fmt.Println("========================================") + fmt.Println("Scan this QR code with WhatsApp on your phone:") + qrterminal.GenerateHalfBlock(evt.Code, qrterminal.L, os.Stdout) + fmt.Println("========================================") + + // Publish QR code event + c.eventBus.Publish(events.WhatsAppQRCodeEvent(ctx, c.id, evt.Code)) + + case "success": + logging.Info("Pairing successful", "account_id", c.id, "phone", c.phoneNumber) + c.eventBus.Publish(events.WhatsAppPairSuccessEvent(ctx, c.id)) + + case "timeout": + logging.Warn("QR code timeout", "account_id", c.id) + c.eventBus.Publish(events.WhatsAppQRTimeoutEvent(ctx, c.id)) + + case "error": + logging.Error("QR code error", "account_id", c.id, "error", evt.Error) + c.eventBus.Publish(events.WhatsAppQRErrorEvent(ctx, c.id, fmt.Errorf("%v", evt.Error))) + + default: + logging.Info("Pairing event", "account_id", c.id, "event", evt.Event) + c.eventBus.Publish(events.WhatsAppPairEventGeneric(ctx, c.id, evt.Event, map[string]any{ + "code": evt.Code, + })) + } + } + } else { + // Already paired, just connect + if err := client.Connect(); err != nil { + return fmt.Errorf("failed to connect: %w", err) + } + } + + if deviceStore.PushName == "" { + deviceStore.PushName = fmt.Sprintf("WhatsHooked %s", c.phoneNumber) + if err := deviceStore.Save(ctx); err != nil { + logging.Error("failed to save device store", "account_id", c.id) + } + } + + if client.IsConnected() { + err := client.SendPresence(ctx, types.PresenceAvailable) + if err != nil { + logging.Warn("Failed to send presence", "account_id", c.id, "error", err) + } else { + logging.Debug("Sent presence update", "account_id", c.id) + } + } + + // Start keep-alive routine + c.startKeepAlive() + + logging.Info("WhatsApp client connected", "account_id", c.id, "phone", c.phoneNumber) + return nil +} + +// Disconnect closes the WhatsApp connection +func (c *Client) Disconnect() error { + // Stop keep-alive + if c.keepAliveCancel != nil { + c.keepAliveCancel() + } + + if c.client != nil { + c.client.Disconnect() + } + + logging.Info("WhatsApp client disconnected", "account_id", c.id) + return nil +} + +// IsConnected returns whether the client is connected +func (c *Client) IsConnected() bool { + if c.client == nil { + return false + } + return c.client.IsConnected() +} + +// GetID returns the client ID +func (c *Client) GetID() string { + return c.id +} + +// GetPhoneNumber returns the phone number +func (c *Client) GetPhoneNumber() string { + return c.phoneNumber +} + +// GetType returns the client type +func (c *Client) GetType() string { + return "whatsmeow" +} + +// SendTextMessage sends a text message +func (c *Client) SendTextMessage(ctx context.Context, jid types.JID, text string) (string, error) { + if ctx == nil { + ctx = context.Background() + } + + if c.client == nil { + err := fmt.Errorf("client not initialized") + c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, jid.String(), text, err)) + return "", err + } + + msg := &waE2E.Message{ + Conversation: proto.String(text), + } + + resp, err := c.client.SendMessage(ctx, jid, msg) + if err != nil { + c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, jid.String(), text, err)) + return "", fmt.Errorf("failed to send message: %w", err) + } + + logging.Debug("Message sent", "account_id", c.id, "to", jid.String()) + c.eventBus.Publish(events.MessageSentEvent(ctx, c.id, resp.ID, jid.String(), text)) + return resp.ID, nil +} + +// SendImage sends an image message +func (c *Client) SendImage(ctx context.Context, jid types.JID, imageData []byte, mimeType string, caption string) (string, error) { + if ctx == nil { + ctx = context.Background() + } + + if c.client == nil { + err := fmt.Errorf("client not initialized") + c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, jid.String(), caption, err)) + return "", err + } + + // Upload the image + uploaded, err := c.client.Upload(ctx, imageData, whatsmeow.MediaImage) + if err != nil { + c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, jid.String(), caption, err)) + return "", fmt.Errorf("failed to upload image: %w", err) + } + + // Create image message + msg := &waE2E.Message{ + ImageMessage: &waE2E.ImageMessage{ + URL: proto.String(uploaded.URL), + DirectPath: proto.String(uploaded.DirectPath), + MediaKey: uploaded.MediaKey, + Mimetype: proto.String(mimeType), + FileEncSHA256: uploaded.FileEncSHA256, + FileSHA256: uploaded.FileSHA256, + FileLength: proto.Uint64(uint64(len(imageData))), + }, + } + + // Add caption if provided + if caption != "" { + msg.ImageMessage.Caption = proto.String(caption) + } + + // Send the message + resp, err := c.client.SendMessage(ctx, jid, msg) + if err != nil { + c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, jid.String(), caption, err)) + return "", fmt.Errorf("failed to send image: %w", err) + } + + logging.Debug("Image sent", "account_id", c.id, "to", jid.String()) + c.eventBus.Publish(events.MessageSentEvent(ctx, c.id, resp.ID, jid.String(), caption)) + return resp.ID, nil +} + +// SendVideo sends a video message +func (c *Client) SendVideo(ctx context.Context, jid types.JID, videoData []byte, mimeType string, caption string) (string, error) { + if ctx == nil { + ctx = context.Background() + } + + if c.client == nil { + err := fmt.Errorf("client not initialized") + c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, jid.String(), caption, err)) + return "", err + } + + // Upload the video + uploaded, err := c.client.Upload(ctx, videoData, whatsmeow.MediaVideo) + if err != nil { + c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, jid.String(), caption, err)) + return "", fmt.Errorf("failed to upload video: %w", err) + } + + // Create video message + msg := &waE2E.Message{ + VideoMessage: &waE2E.VideoMessage{ + URL: proto.String(uploaded.URL), + DirectPath: proto.String(uploaded.DirectPath), + MediaKey: uploaded.MediaKey, + Mimetype: proto.String(mimeType), + FileEncSHA256: uploaded.FileEncSHA256, + FileSHA256: uploaded.FileSHA256, + FileLength: proto.Uint64(uint64(len(videoData))), + }, + } + + // Add caption if provided + if caption != "" { + msg.VideoMessage.Caption = proto.String(caption) + } + + // Send the message + resp, err := c.client.SendMessage(ctx, jid, msg) + if err != nil { + c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, jid.String(), caption, err)) + return "", fmt.Errorf("failed to send video: %w", err) + } + + logging.Debug("Video sent", "account_id", c.id, "to", jid.String()) + c.eventBus.Publish(events.MessageSentEvent(ctx, c.id, resp.ID, jid.String(), caption)) + return resp.ID, nil +} + +// SendDocument sends a document message +func (c *Client) SendDocument(ctx context.Context, jid types.JID, documentData []byte, mimeType string, filename string, caption string) (string, error) { + if ctx == nil { + ctx = context.Background() + } + + if c.client == nil { + err := fmt.Errorf("client not initialized") + c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, jid.String(), caption, err)) + return "", err + } + + // Upload the document + uploaded, err := c.client.Upload(ctx, documentData, whatsmeow.MediaDocument) + if err != nil { + c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, jid.String(), caption, err)) + return "", fmt.Errorf("failed to upload document: %w", err) + } + + // Create document message + msg := &waE2E.Message{ + DocumentMessage: &waE2E.DocumentMessage{ + URL: proto.String(uploaded.URL), + DirectPath: proto.String(uploaded.DirectPath), + MediaKey: uploaded.MediaKey, + Mimetype: proto.String(mimeType), + FileEncSHA256: uploaded.FileEncSHA256, + FileSHA256: uploaded.FileSHA256, + FileLength: proto.Uint64(uint64(len(documentData))), + FileName: proto.String(filename), + }, + } + + // Add caption if provided + if caption != "" { + msg.DocumentMessage.Caption = proto.String(caption) + } + + // Send the message + resp, err := c.client.SendMessage(ctx, jid, msg) + if err != nil { + c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, jid.String(), caption, err)) + return "", fmt.Errorf("failed to send document: %w", err) + } + + logging.Debug("Document sent", "account_id", c.id, "to", jid.String(), "filename", filename) + c.eventBus.Publish(events.MessageSentEvent(ctx, c.id, resp.ID, jid.String(), caption)) + return resp.ID, nil +} + +// handleEvent processes WhatsApp events +func (c *Client) handleEvent(evt interface{}) { + ctx := context.Background() + + switch v := evt.(type) { + case *waEvents.Message: + logging.Debug("Message received", "account_id", c.id, "from", v.Info.Sender.String()) + + // Extract message content based on type + var text string + var messageType string = "text" + var mimeType string + var filename string + var mediaBase64 string + var mediaURL string + + // Handle text messages + if v.Message.Conversation != nil { + text = *v.Message.Conversation + messageType = "text" + } else if v.Message.ExtendedTextMessage != nil && v.Message.ExtendedTextMessage.Text != nil { + text = *v.Message.ExtendedTextMessage.Text + messageType = "text" + } + + // Handle image messages + if v.Message.ImageMessage != nil { + img := v.Message.ImageMessage + messageType = "image" + mimeType = img.GetMimetype() + + if img.Caption != nil { + text = *img.Caption + } + + // Download image + data, err := c.client.Download(ctx, img) + if err != nil { + logging.Error("Failed to download image", "account_id", c.id, "error", err) + } else { + filename, mediaURL = c.processMediaData(v.Info.ID, data, mimeType, &mediaBase64) + } + } + + // Handle video messages + if v.Message.VideoMessage != nil { + vid := v.Message.VideoMessage + messageType = "video" + mimeType = vid.GetMimetype() + + if vid.Caption != nil { + text = *vid.Caption + } + + // Download video + data, err := c.client.Download(ctx, vid) + if err != nil { + logging.Error("Failed to download video", "account_id", c.id, "error", err) + } else { + filename, mediaURL = c.processMediaData(v.Info.ID, data, mimeType, &mediaBase64) + } + } + + // Handle document messages + if v.Message.DocumentMessage != nil { + doc := v.Message.DocumentMessage + messageType = "document" + mimeType = doc.GetMimetype() + + if doc.FileName != nil { + filename = *doc.FileName + } + + if doc.Caption != nil { + text = *doc.Caption + } + + // Download document + data, err := c.client.Download(ctx, doc) + if err != nil { + logging.Error("Failed to download document", "account_id", c.id, "error", err) + } else { + filename, mediaURL = c.processMediaData(v.Info.ID, data, mimeType, &mediaBase64) + } + } + + // Publish message received event + c.eventBus.Publish(events.MessageReceivedEvent( + ctx, + c.id, + v.Info.ID, + v.Info.Sender.String(), + v.Info.Chat.String(), + text, + v.Info.Timestamp, + v.Info.IsGroup, + "", // group name - TODO: extract from message + "", // sender name - TODO: extract from message + messageType, + mimeType, + filename, + mediaBase64, + mediaURL, + )) + + case *waEvents.Connected: + logging.Info("WhatsApp connected", "account_id", c.id) + + // Get the actual phone number from WhatsApp + phoneNumber := "" + if c.client.Store.ID != nil { + actualPhone := c.client.Store.ID.User + phoneNumber = "+" + actualPhone + + // Update phone number in client if it's different + if c.phoneNumber != phoneNumber { + c.phoneNumber = phoneNumber + logging.Info("Updated phone number from WhatsApp", "account_id", c.id, "phone", phoneNumber) + } + } else if c.phoneNumber != "" { + phoneNumber = c.phoneNumber + } + + c.eventBus.Publish(events.WhatsAppConnectedEvent(ctx, c.id, phoneNumber)) + + case *waEvents.Disconnected: + logging.Warn("WhatsApp disconnected", "account_id", c.id) + c.eventBus.Publish(events.WhatsAppDisconnectedEvent(ctx, c.id, "connection lost")) + + case *waEvents.Receipt: + // Handle delivery and read receipts + if v.Type == types.ReceiptTypeDelivered { + for _, messageID := range v.MessageIDs { + logging.Debug("Message delivered", "account_id", c.id, "message_id", messageID, "from", v.Sender.String()) + c.eventBus.Publish(events.MessageDeliveredEvent(ctx, c.id, messageID, v.Sender.String(), v.Timestamp)) + } + } else if v.Type == types.ReceiptTypeRead { + for _, messageID := range v.MessageIDs { + logging.Debug("Message read", "account_id", c.id, "message_id", messageID, "from", v.Sender.String()) + c.eventBus.Publish(events.MessageReadEvent(ctx, c.id, messageID, v.Sender.String(), v.Timestamp)) + } + } + } +} + +// startKeepAlive starts a goroutine that sends presence updates to keep the connection alive +func (c *Client) startKeepAlive() { + ctx, cancel := context.WithCancel(context.Background()) + c.keepAliveCancel = cancel + + go func() { + ticker := time.NewTicker(60 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + logging.Debug("Keep-alive stopped", "account_id", c.id) + return + case <-ticker.C: + if c.client != nil && c.client.IsConnected() { + err := c.client.SendPresence(ctx, types.PresenceAvailable) + if err != nil { + logging.Warn("Failed to send presence", "account_id", c.id, "error", err) + } else { + logging.Debug("Sent presence update", "account_id", c.id) + } + } + } + } + }() + + logging.Info("Keep-alive started", "account_id", c.id) +} + +// processMediaData processes media based on the configured mode +func (c *Client) processMediaData(messageID string, data []byte, mimeType string, mediaBase64 *string) (string, string) { + mode := c.mediaConfig.Mode + var filename string + var mediaURL string + + // Generate filename + ext := getExtensionFromMimeType(mimeType) + hash := sha256.Sum256(data) + hashStr := hex.EncodeToString(hash[:8]) + filename = fmt.Sprintf("%s_%s%s", messageID, hashStr, ext) + + // Handle base64 mode + if mode == "base64" || mode == "both" { + *mediaBase64 = base64.StdEncoding.EncodeToString(data) + } + + // Handle link mode + if mode == "link" || mode == "both" { + // Save file to disk + filePath, err := c.saveMediaFile(messageID, data, mimeType) + if err != nil { + logging.Error("Failed to save media file", "account_id", c.id, "message_id", messageID, "error", err) + } else { + // Extract just the filename from the full path + filename = filepath.Base(filePath) + mediaURL = c.generateMediaURL(messageID, filename) + } + } + + return filename, mediaURL +} + +// saveMediaFile saves media data to disk and returns the file path +func (c *Client) saveMediaFile(messageID string, data []byte, mimeType string) (string, error) { + // Create account-specific media directory + mediaDir := filepath.Join(c.mediaConfig.DataPath, c.id) + if err := os.MkdirAll(mediaDir, 0755); err != nil { + return "", fmt.Errorf("failed to create media directory: %w", err) + } + + // Generate unique filename using message ID and hash + hash := sha256.Sum256(data) + hashStr := hex.EncodeToString(hash[:8]) + ext := getExtensionFromMimeType(mimeType) + filename := fmt.Sprintf("%s_%s%s", messageID, hashStr, ext) + + // Full path to file + filePath := filepath.Join(mediaDir, filename) + + // Write file + if err := os.WriteFile(filePath, data, 0644); err != nil { + return "", fmt.Errorf("failed to write media file: %w", err) + } + + return filePath, nil +} + +// generateMediaURL generates a URL for accessing stored media +func (c *Client) generateMediaURL(messageID, filename string) string { + baseURL := c.mediaConfig.BaseURL + if baseURL == "" { + baseURL = "http://localhost:8080" + } + return fmt.Sprintf("%s/api/media/%s/%s", baseURL, c.id, filename) +} + +// getExtensionFromMimeType returns the file extension for a given MIME type +func getExtensionFromMimeType(mimeType string) string { + extensions := map[string]string{ + // Images + "image/jpeg": ".jpg", + "image/jpg": ".jpg", + "image/png": ".png", + "image/gif": ".gif", + "image/webp": ".webp", + "image/bmp": ".bmp", + "image/svg+xml": ".svg", + + // Videos + "video/mp4": ".mp4", + "video/mpeg": ".mpeg", + "video/quicktime": ".mov", + "video/x-msvideo": ".avi", + "video/webm": ".webm", + "video/3gpp": ".3gp", + + // Documents + "application/pdf": ".pdf", + "application/msword": ".doc", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx", + "application/vnd.ms-excel": ".xls", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx", + "application/vnd.ms-powerpoint": ".ppt", + "application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx", + "text/plain": ".txt", + "text/html": ".html", + "application/zip": ".zip", + "application/x-rar-compressed": ".rar", + "application/x-7z-compressed": ".7z", + "application/json": ".json", + "application/xml": ".xml", + + // Audio + "audio/mpeg": ".mp3", + "audio/ogg": ".ogg", + "audio/wav": ".wav", + "audio/aac": ".aac", + "audio/x-m4a": ".m4a", + } + + if ext, ok := extensions[mimeType]; ok { + return ext + } + return "" +}