Whatsapp Business support
This commit is contained in:
216
README.md
216
README.md
@@ -1,6 +1,6 @@
|
|||||||
# WhatsHooked
|
# 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.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -12,6 +12,7 @@ A service that connects to WhatsApp via the whatsmeow API and forwards messages
|
|||||||
## Phase 1 Features
|
## Phase 1 Features
|
||||||
|
|
||||||
- **Multi-Account Support**: Connect to multiple WhatsApp accounts simultaneously
|
- **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
|
- **Webhook Integration**: Register multiple webhooks to receive WhatsApp messages
|
||||||
- **Two-Way Communication**: Webhooks can respond with messages to send back to WhatsApp
|
- **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
|
- **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/config**: Configuration management and persistence
|
||||||
- **internal/logging**: Structured logging using Go's slog package
|
- **internal/logging**: Structured logging using Go's slog package
|
||||||
- **internal/events**: Event bus for publish/subscribe messaging between components
|
- **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
|
- **internal/hooks**: Webhook management and message forwarding
|
||||||
- **cmd/server**: Main server application
|
- **cmd/server**: Main server application
|
||||||
- **cmd/cli**: Command-line interface for management
|
- **cmd/cli**: Command-line interface for management
|
||||||
@@ -88,9 +91,23 @@ Edit the configuration file to add your WhatsApp accounts and webhooks:
|
|||||||
},
|
},
|
||||||
"whatsapp": [
|
"whatsapp": [
|
||||||
{
|
{
|
||||||
"id": "account1",
|
"id": "personal",
|
||||||
|
"type": "whatsmeow",
|
||||||
"phone_number": "+1234567890",
|
"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": [
|
"hooks": [
|
||||||
@@ -122,8 +139,20 @@ Edit the configuration file to add your WhatsApp accounts and webhooks:
|
|||||||
|
|
||||||
**WhatsApp Account Configuration:**
|
**WhatsApp Account Configuration:**
|
||||||
- `id`: Unique identifier for this account
|
- `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
|
- `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:**
|
**Hook Configuration:**
|
||||||
- `id`: Unique identifier for this hook
|
- `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
|
- 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
|
- 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
|
## Usage
|
||||||
|
|
||||||
### Starting the Server
|
### Starting the Server
|
||||||
@@ -365,17 +534,21 @@ Examples with `default_country_code: "27"`:
|
|||||||
|
|
||||||
The server exposes the following HTTP endpoints:
|
The server exposes the following HTTP endpoints:
|
||||||
|
|
||||||
|
**Public Endpoints:**
|
||||||
- `GET /health` - Health check (no authentication required)
|
- `GET /health` - Health check (no authentication required)
|
||||||
- `GET /api/hooks` - List all hooks (requires authentication if enabled)
|
- `GET/POST /webhooks/whatsapp/{accountID}` - Business API webhook verification and events (no authentication, validated by Meta's verify_token)
|
||||||
- `POST /api/hooks/add` - Add a new hook (requires authentication if enabled)
|
|
||||||
- `POST /api/hooks/remove` - Remove a hook (requires authentication if enabled)
|
**Protected Endpoints (require authentication if enabled):**
|
||||||
- `GET /api/accounts` - List all WhatsApp accounts (requires authentication if enabled)
|
- `GET /api/hooks` - List all hooks
|
||||||
- `POST /api/accounts/add` - Add a new WhatsApp account (requires authentication if enabled)
|
- `POST /api/hooks/add` - Add a new hook
|
||||||
- `POST /api/send` - Send a message (requires authentication if enabled)
|
- `POST /api/hooks/remove` - Remove a hook
|
||||||
- `POST /api/send/image` - Send an image (requires authentication if enabled)
|
- `GET /api/accounts` - List all WhatsApp accounts
|
||||||
- `POST /api/send/video` - Send a video (requires authentication if enabled)
|
- `POST /api/accounts/add` - Add a new WhatsApp account
|
||||||
- `POST /api/send/document` - Send a document (requires authentication if enabled)
|
- `POST /api/send` - Send a message
|
||||||
- `GET /api/media/{accountID}/{filename}` - Serve media files (requires authentication if enabled)
|
- `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
|
## WhatsApp JID Format
|
||||||
|
|
||||||
@@ -394,12 +567,25 @@ The server accepts both full JID format and plain phone numbers. When using plai
|
|||||||
whatshooked/
|
whatshooked/
|
||||||
├── cmd/
|
├── cmd/
|
||||||
│ ├── server/ # Main server application
|
│ ├── server/ # Main server application
|
||||||
|
│ │ ├── main.go
|
||||||
|
│ │ ├── routes.go
|
||||||
|
│ │ ├── routes_*.go # Route handlers
|
||||||
|
│ │ └── routes_businessapi.go # Business API webhooks
|
||||||
│ └── cli/ # CLI tool
|
│ └── cli/ # CLI tool
|
||||||
├── internal/
|
├── internal/
|
||||||
│ ├── config/ # Configuration management
|
│ ├── config/ # Configuration management
|
||||||
│ ├── events/ # Event bus and event types
|
│ ├── events/ # Event bus and event types
|
||||||
│ ├── logging/ # Structured logging
|
│ ├── logging/ # Structured logging
|
||||||
│ ├── whatsapp/ # WhatsApp client management
|
│ ├── 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
|
│ ├── hooks/ # Webhook management
|
||||||
│ └── utils/ # Utility functions (phone formatting, etc.)
|
│ └── utils/ # Utility functions (phone formatting, etc.)
|
||||||
├── config.example.json # Example configuration
|
├── config.example.json # Example configuration
|
||||||
|
|||||||
4
TODO.md
4
TODO.md
@@ -4,8 +4,10 @@
|
|||||||
- [✔️] Docker Server Support with docker-compose.yml (Basic Config from .ENV file)
|
- [✔️] Docker Server Support with docker-compose.yml (Basic Config from .ENV file)
|
||||||
- [✔️] Authentication options for cli
|
- [✔️] 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.)
|
- [✔️] **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 connection for Whatsmeo
|
||||||
- [ ] Optional Postgres server,database for event saving and hook registration
|
- [ ] Optional Postgres server,database for event saving and hook registration
|
||||||
- [✔️] Optional Event logging into directory for each type
|
- [✔️] 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)
|
- [ ] 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.
|
||||||
@@ -32,6 +32,9 @@ func (s *Server) setupRoutes() *http.ServeMux {
|
|||||||
// Serve media files (with auth)
|
// Serve media files (with auth)
|
||||||
mux.HandleFunc("/api/media/", s.handleServeMedia)
|
mux.HandleFunc("/api/media/", s.handleServeMedia)
|
||||||
|
|
||||||
|
// Business API webhooks (no auth - Meta validates via verify_token)
|
||||||
|
mux.HandleFunc("/webhooks/whatsapp/", s.handleBusinessAPIWebhook)
|
||||||
|
|
||||||
return mux
|
return mux
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
151
cmd/server/routes_businessapi.go
Normal file
151
cmd/server/routes_businessapi.go
Normal file
@@ -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 ""
|
||||||
|
}
|
||||||
@@ -9,10 +9,23 @@
|
|||||||
},
|
},
|
||||||
"whatsapp": [
|
"whatsapp": [
|
||||||
{
|
{
|
||||||
"id": "acc1",
|
"id": "personal",
|
||||||
|
"type": "whatsmeow",
|
||||||
"phone_number": "+1234567890",
|
"phone_number": "+1234567890",
|
||||||
"session_path": "./sessions/account1",
|
"session_path": "./sessions/personal",
|
||||||
"show_qr": true
|
"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": [
|
"hooks": [
|
||||||
|
|||||||
@@ -29,9 +29,21 @@ type ServerConfig struct {
|
|||||||
// WhatsAppConfig holds configuration for a WhatsApp account
|
// WhatsAppConfig holds configuration for a WhatsApp account
|
||||||
type WhatsAppConfig struct {
|
type WhatsAppConfig struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
|
Type string `json:"type"` // "whatsmeow" or "business-api"
|
||||||
PhoneNumber string `json:"phone_number"`
|
PhoneNumber string `json:"phone_number"`
|
||||||
SessionPath string `json:"session_path"`
|
SessionPath string `json:"session_path,omitempty"`
|
||||||
ShowQR bool `json:"show_qr,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
|
// Hook represents a registered webhook
|
||||||
@@ -114,6 +126,19 @@ func Load(path string) (*Config, error) {
|
|||||||
cfg.Database.SQLitePath = "./data/events.db"
|
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
|
return &cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
354
internal/whatsapp/businessapi/client.go
Normal file
354
internal/whatsapp/businessapi/client.go
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
288
internal/whatsapp/businessapi/events.go
Normal file
288
internal/whatsapp/businessapi/events.go
Normal file
@@ -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 ""
|
||||||
|
}
|
||||||
138
internal/whatsapp/businessapi/media.go
Normal file
138
internal/whatsapp/businessapi/media.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
193
internal/whatsapp/businessapi/types.go
Normal file
193
internal/whatsapp/businessapi/types.go
Normal file
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
34
internal/whatsapp/interface.go
Normal file
34
internal/whatsapp/interface.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
171
internal/whatsapp/manager.go
Normal file
171
internal/whatsapp/manager.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
678
internal/whatsapp/whatsmeow/client.go
Normal file
678
internal/whatsapp/whatsmeow/client.go
Normal file
@@ -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 ""
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user