Whatsapp Business enhancements
Some checks failed
CI / Test (1.22) (push) Failing after -22m39s
CI / Test (1.23) (push) Failing after -22m40s
CI / Build (push) Successful in -25m42s
CI / Lint (push) Failing after -25m28s

This commit is contained in:
2025-12-30 11:35:10 +02:00
parent d80a6433b9
commit 147dac9b60
4 changed files with 543 additions and 19 deletions

View File

@@ -11,6 +11,7 @@ A Go library and service that connects to WhatsApp and forwards messages to regi
## Documentation ## Documentation
- [WhatsApp Business API Setup](WHATSAPP_BUSINESS.md) - Complete guide for configuring WhatsApp Business API credentials
- [TODO List](TODO.md) - Current tasks and planned improvements - [TODO List](TODO.md) - Current tasks and planned improvements
- [AI Usage Guidelines](AI_USE.md) - Rules when using AI tools with this project - [AI Usage Guidelines](AI_USE.md) - Rules when using AI tools with this project
- [Project Plan](PLAN.md) - Development plan and architecture decisions - [Project Plan](PLAN.md) - Development plan and architecture decisions

281
WHATSAPP_BUSINESS.md Normal file
View File

@@ -0,0 +1,281 @@
# WhatsApp Business API Setup Guide
This guide will help you set up WhatsApp Business API credentials for use with WhatsHooked.
## Common Error: "Object does not exist or missing permissions"
If you see this error:
```
Failed to connect client account_id=test error="API returned status 400:
{\"error\":{\"message\":\"Unsupported get request. Object with ID 'XXXXXXXXX' does not exist,
cannot be loaded due to missing permissions, or does not support this operation...\",
\"type\":\"GraphMethodException\",\"code\":100,\"error_subcode\":33...}}"
```
This means your **access token lacks the required WhatsApp Business API permissions**.
## Prerequisites
Before you begin, ensure you have:
1. A Meta Business Account
2. WhatsApp Business API access (approved by Meta)
3. A verified WhatsApp Business phone number
4. Admin access to your Meta Business Manager
## Step 1: Access Meta Business Manager
1. Go to [Meta Business Manager](https://business.facebook.com/)
2. Select your business account
3. Navigate to **Business Settings** (gear icon)
## Step 2: Create a System User (Recommended for Production)
System Users provide permanent access tokens that don't expire with user sessions.
1. In Business Settings, go to **Users****System Users**
2. Click **Add** to create a new system user
3. Enter a name (e.g., "WhatsHooked API Access")
4. Select **Admin** role
5. Click **Create System User**
## Step 3: Assign the System User to WhatsApp
1. In the System User details, scroll to **Assign Assets**
2. Click **Add Assets**
3. Select **Apps**
4. Choose your WhatsApp Business app
5. Grant **Full Control**
6. Click **Add People**
7. Select **WhatsApp Accounts**
8. Choose your WhatsApp Business Account
9. Grant **Full Control**
10. Click **Save Changes**
## Step 4: Generate Access Token with Required Permissions
1. In the System User details, click **Generate New Token**
2. Select your app from the dropdown
3. **IMPORTANT**: Check these permissions:
-`whatsapp_business_management`
-`whatsapp_business_messaging`
4. Set token expiration (choose "Never" for permanent tokens)
5. Click **Generate Token**
6. **CRITICAL**: Copy the token immediately - you won't see it again!
### Verify Token Permissions
You can verify your token has the correct permissions:
```bash
# Replace YOUR_TOKEN with your actual access token
curl -X GET 'https://graph.facebook.com/v21.0/debug_token?input_token=YOUR_TOKEN' \
-H 'Authorization: Bearer YOUR_TOKEN'
```
Look for `"scopes"` in the response - it should include:
```json
{
"data": {
"scopes": [
"whatsapp_business_management",
"whatsapp_business_messaging",
...
]
}
}
```
## Step 5: Get Your Phone Number ID
The Phone Number ID is **NOT** your actual phone number - it's a unique identifier from Meta.
### Method 1: Via WhatsApp Manager (Easiest)
1. Go to [WhatsApp Manager](https://business.facebook.com/wa/manage/home/)
2. Select your WhatsApp Business Account
3. Click **API Setup** in the left sidebar
4. Copy the **Phone Number ID** (looks like: `123456789012345`)
### Method 2: Via API
```bash
# Replace YOUR_TOKEN and YOUR_BUSINESS_ACCOUNT_ID
curl -X GET 'https://graph.facebook.com/v21.0/YOUR_BUSINESS_ACCOUNT_ID/phone_numbers' \
-H 'Authorization: Bearer YOUR_TOKEN'
```
Response:
```json
{
"data": [
{
"verified_name": "Your Business Name",
"display_phone_number": "+1 234-567-8900",
"id": "123456789012345", // <- This is your Phone Number ID
"quality_rating": "GREEN"
}
]
}
```
## Step 6: Get Your Business Account ID (Optional)
```bash
# Get all WhatsApp Business Accounts you have access to
curl -X GET 'https://graph.facebook.com/v21.0/me/businesses' \
-H 'Authorization: Bearer YOUR_TOKEN'
```
Or find it in WhatsApp Manager:
1. Go to WhatsApp Manager
2. Click on **Settings** (gear icon)
3. The Business Account ID is shown in the URL: `https://business.facebook.com/wa/manage/home/?waba_id=XXXXXXXXX`
## Step 7: Test Your Credentials
Before configuring WhatsHooked, test your credentials:
```bash
# Replace PHONE_NUMBER_ID and YOUR_TOKEN
curl -X GET 'https://graph.facebook.com/v21.0/PHONE_NUMBER_ID' \
-H 'Authorization: Bearer YOUR_TOKEN'
```
If successful, you'll get a response like:
```json
{
"verified_name": "Your Business Name",
"display_phone_number": "+1 234-567-8900",
"id": "123456789012345",
"quality_rating": "GREEN"
}
```
If you get an error like `"error_subcode":33`, your token lacks permissions - go back to Step 4.
## Step 8: Configure WhatsHooked
Update your `config.json` with the Business API configuration:
```json
{
"whatsapp": [
{
"id": "business",
"type": "business-api",
"phone_number": "+1234567890",
"business_api": {
"phone_number_id": "123456789012345",
"access_token": "EAAxxxxxxxxxxxx_your_permanent_token_here",
"business_account_id": "987654321098765",
"api_version": "v21.0"
}
}
]
}
```
### Configuration Fields
| Field | Required | Description |
|-------|----------|-------------|
| `id` | Yes | Unique identifier for this account in WhatsHooked |
| `type` | Yes | Must be `"business-api"` |
| `phone_number` | Yes | Your WhatsApp Business phone number (E.164 format) |
| `phone_number_id` | Yes | Phone Number ID from Meta (from Step 5) |
| `access_token` | Yes | Permanent access token (from Step 4) |
| `business_account_id` | No | WhatsApp Business Account ID (optional, for reference) |
| `api_version` | No | Graph API version (defaults to `"v21.0"`) |
## Step 9: Start WhatsHooked
```bash
./bin/whatshook-server -config config.json
```
You should see:
```
INFO Business API client connected account_id=business phone=+1234567890
```
If you see `Failed to connect client`, check the error message and verify:
1. Phone Number ID is correct
2. Access token has required permissions
3. Access token hasn't expired
4. Business Account has WhatsApp API access enabled
## Troubleshooting
### Error: "Object with ID does not exist" (error_subcode: 33)
**Cause**: One of the following:
- Incorrect Phone Number ID
- Access token lacks permissions
- Access token expired
**Fix**:
1. Verify token permissions (see Step 4)
2. Double-check Phone Number ID (see Step 5)
3. Generate a new token if needed
### Error: "Invalid OAuth access token"
**Cause**: Token is invalid or expired
**Fix**: Generate a new access token (Step 4)
### Error: "Application does not have permission"
**Cause**: App not added to WhatsApp Business Account
**Fix**: Complete Step 3 to assign System User to WhatsApp
### Token Expires Too Quickly
**Issue**: Using a User Access Token instead of System User token
**Fix**:
- Use a System User (Step 2) for permanent tokens
- User Access Tokens expire in 60 days
- System User tokens can be set to "Never expire"
## Security Best Practices
1. **Never commit tokens to version control**
- Add `config.json` to `.gitignore`
- Use environment variables for sensitive data
2. **Rotate tokens regularly**
- Even "permanent" tokens should be rotated periodically
- Revoke old tokens when generating new ones
3. **Use System Users for production**
- Don't use personal User Access Tokens
- System Users provide better security and permanence
4. **Limit token permissions**
- Only grant the minimum required permissions
- For WhatsHooked, you only need:
- `whatsapp_business_management`
- `whatsapp_business_messaging`
5. **Monitor token usage**
- Check token status regularly via debug_token endpoint
- Watch for unexpected API calls
## Additional Resources
- [WhatsApp Business Platform Documentation](https://developers.facebook.com/docs/whatsapp)
- [Graph API Reference](https://developers.facebook.com/docs/graph-api)
- [System Users Guide](https://www.facebook.com/business/help/503306463479099)
- [WhatsApp Business API Getting Started](https://developers.facebook.com/docs/whatsapp/cloud-api/get-started)
## Support
If you continue to have issues:
1. Verify your Meta Business Account has WhatsApp API access
2. Check that your phone number is verified in WhatsApp Manager
3. Ensure you're using Graph API v21.0 or later
4. Review the [WhatsApp Business API changelog](https://developers.facebook.com/docs/whatsapp/changelog) for updates

View File

@@ -68,38 +68,87 @@ func NewClient(cfg config.WhatsAppConfig, eventBus *events.EventBus, mediaConfig
// Connect validates the Business API credentials // Connect validates the Business API credentials
func (c *Client) Connect(ctx context.Context) error { func (c *Client) Connect(ctx context.Context) error {
// Validate credentials by making a test request to get phone number details logging.Info("Validating WhatsApp Business API credentials", "account_id", c.id)
url := fmt.Sprintf("https://graph.facebook.com/%s/%s",
c.config.APIVersion,
c.config.PhoneNumberID)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil) // Step 1: Validate token and check permissions
if err != nil { tokenInfo, err := c.validateToken(ctx)
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 { if err != nil {
c.eventBus.Publish(events.WhatsAppPairFailedEvent(ctx, c.id, err)) c.eventBus.Publish(events.WhatsAppPairFailedEvent(ctx, c.id, err))
return fmt.Errorf("failed to validate credentials: %w", err) return fmt.Errorf("token validation failed: %w", err)
} }
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { // Log token information
body, _ := io.ReadAll(resp.Body) logging.Info("Access token validated",
err := fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(body)) "account_id", c.id,
"token_type", tokenInfo.Type,
"app", tokenInfo.Application,
"app_id", tokenInfo.AppID,
"expires", c.formatExpiry(tokenInfo.ExpiresAt),
"scopes", strings.Join(tokenInfo.Scopes, ", "))
// Check for required permissions
requiredScopes := []string{"whatsapp_business_management", "whatsapp_business_messaging"}
missingScopes := c.checkMissingScopes(tokenInfo.Scopes, requiredScopes)
if len(missingScopes) > 0 {
err := fmt.Errorf("token missing required permissions: %s", strings.Join(missingScopes, ", "))
logging.Error("Insufficient token permissions",
"account_id", c.id,
"missing_scopes", strings.Join(missingScopes, ", "),
"current_scopes", strings.Join(tokenInfo.Scopes, ", "))
c.eventBus.Publish(events.WhatsAppPairFailedEvent(ctx, c.id, err)) c.eventBus.Publish(events.WhatsAppPairFailedEvent(ctx, c.id, err))
return err return err
} }
// Step 2: Get phone number details
phoneDetails, err := c.getPhoneNumberDetails(ctx)
if err != nil {
c.eventBus.Publish(events.WhatsAppPairFailedEvent(ctx, c.id, err))
return fmt.Errorf("failed to get phone number details: %w", err)
}
// Log phone number information
logging.Info("Phone number details retrieved",
"account_id", c.id,
"phone_number_id", phoneDetails.ID,
"display_number", phoneDetails.DisplayPhoneNumber,
"verified_name", phoneDetails.VerifiedName,
"verification_status", phoneDetails.CodeVerificationStatus,
"quality_rating", phoneDetails.QualityRating,
"throughput_level", phoneDetails.Throughput.Level)
// Warn if phone number is not verified
if phoneDetails.CodeVerificationStatus != "VERIFIED" {
logging.Warn("Phone number is not verified - messaging capabilities may be limited",
"account_id", c.id,
"status", phoneDetails.CodeVerificationStatus)
}
// Step 3: Get business account details (if business_account_id is provided)
if c.config.BusinessAccountID != "" {
businessDetails, err := c.getBusinessAccountDetails(ctx)
if err != nil {
logging.Warn("Failed to get business account details (non-critical)",
"account_id", c.id,
"business_account_id", c.config.BusinessAccountID,
"error", err)
} else {
logging.Info("Business account details retrieved",
"account_id", c.id,
"business_account_id", businessDetails.ID,
"business_name", businessDetails.Name,
"timezone_id", businessDetails.TimezoneID)
}
}
c.mu.Lock() c.mu.Lock()
c.connected = true c.connected = true
c.mu.Unlock() c.mu.Unlock()
logging.Info("Business API client connected", "account_id", c.id, "phone", c.phoneNumber) logging.Info("Business API client connected successfully",
c.eventBus.Publish(events.WhatsAppConnectedEvent(ctx, c.id, c.phoneNumber)) "account_id", c.id,
"phone", phoneDetails.DisplayPhoneNumber,
"verified_name", phoneDetails.VerifiedName)
c.eventBus.Publish(events.WhatsAppConnectedEvent(ctx, c.id, phoneDetails.DisplayPhoneNumber))
return nil return nil
} }
@@ -340,3 +389,154 @@ func jidToPhoneNumber(jid types.JID) string {
return phone return phone
} }
// validateToken validates the access token and returns token information
func (c *Client) validateToken(ctx context.Context) (*TokenDebugData, error) {
url := fmt.Sprintf("https://graph.facebook.com/%s/debug_token?input_token=%s",
c.config.APIVersion,
c.config.AccessToken)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create token validation 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 validate token: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read token validation response: %w", err)
}
if resp.StatusCode != http.StatusOK {
var errResp ErrorResponse
if err := json.Unmarshal(body, &errResp); err == nil {
return nil, fmt.Errorf("token validation failed: %s (code: %d)", errResp.Error.Message, errResp.Error.Code)
}
return nil, fmt.Errorf("token validation returned status %d: %s", resp.StatusCode, string(body))
}
var tokenResp TokenDebugResponse
if err := json.Unmarshal(body, &tokenResp); err != nil {
return nil, fmt.Errorf("failed to parse token validation response: %w", err)
}
if !tokenResp.Data.IsValid {
return nil, fmt.Errorf("access token is invalid or expired")
}
return &tokenResp.Data, nil
}
// getPhoneNumberDetails retrieves details about the phone number
func (c *Client) getPhoneNumberDetails(ctx context.Context) (*PhoneNumberDetails, error) {
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 nil, fmt.Errorf("failed to create phone number details 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 phone number details: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read phone number details response: %w", err)
}
if resp.StatusCode != http.StatusOK {
var errResp ErrorResponse
if err := json.Unmarshal(body, &errResp); err == nil {
return nil, fmt.Errorf("API error: %s (code: %d, subcode: %d)",
errResp.Error.Message, errResp.Error.Code, errResp.Error.ErrorSubcode)
}
return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(body))
}
var phoneDetails PhoneNumberDetails
if err := json.Unmarshal(body, &phoneDetails); err != nil {
return nil, fmt.Errorf("failed to parse phone number details: %w", err)
}
return &phoneDetails, nil
}
// getBusinessAccountDetails retrieves details about the business account
func (c *Client) getBusinessAccountDetails(ctx context.Context) (*BusinessAccountDetails, error) {
url := fmt.Sprintf("https://graph.facebook.com/%s/%s",
c.config.APIVersion,
c.config.BusinessAccountID)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create business account details 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 business account details: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read business account details response: %w", err)
}
if resp.StatusCode != http.StatusOK {
var errResp ErrorResponse
if err := json.Unmarshal(body, &errResp); err == nil {
return nil, fmt.Errorf("API error: %s (code: %d)", errResp.Error.Message, errResp.Error.Code)
}
return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(body))
}
var businessDetails BusinessAccountDetails
if err := json.Unmarshal(body, &businessDetails); err != nil {
return nil, fmt.Errorf("failed to parse business account details: %w", err)
}
return &businessDetails, nil
}
// checkMissingScopes checks which required scopes are missing from the token
func (c *Client) checkMissingScopes(currentScopes []string, requiredScopes []string) []string {
scopeMap := make(map[string]bool)
for _, scope := range currentScopes {
scopeMap[scope] = true
}
var missing []string
for _, required := range requiredScopes {
if !scopeMap[required] {
missing = append(missing, required)
}
}
return missing
}
// formatExpiry formats the expiry timestamp for logging
func (c *Client) formatExpiry(expiresAt int64) string {
if expiresAt == 0 {
return "never"
}
expiryTime := time.Unix(expiresAt, 0)
return expiryTime.Format("2006-01-02 15:04:05 MST")
}

View File

@@ -191,3 +191,45 @@ type WebhookError struct {
Code int `json:"code"` Code int `json:"code"`
Title string `json:"title"` Title string `json:"title"`
} }
// TokenDebugResponse represents the response from debug_token endpoint
type TokenDebugResponse struct {
Data TokenDebugData `json:"data"`
}
// TokenDebugData contains token validation information
type TokenDebugData struct {
AppID string `json:"app_id"`
Type string `json:"type"`
Application string `json:"application"`
DataAccessExpiresAt int64 `json:"data_access_expires_at"`
ExpiresAt int64 `json:"expires_at"`
IsValid bool `json:"is_valid"`
IssuedAt int64 `json:"issued_at,omitempty"`
Scopes []string `json:"scopes"`
UserID string `json:"user_id"`
}
// PhoneNumberDetails represents phone number information from the API
type PhoneNumberDetails struct {
ID string `json:"id"`
VerifiedName string `json:"verified_name"`
CodeVerificationStatus string `json:"code_verification_status"`
DisplayPhoneNumber string `json:"display_phone_number"`
QualityRating string `json:"quality_rating"`
PlatformType string `json:"platform_type"`
Throughput ThroughputInfo `json:"throughput"`
}
// ThroughputInfo contains throughput information
type ThroughputInfo struct {
Level string `json:"level"`
}
// BusinessAccountDetails represents business account information
type BusinessAccountDetails struct {
ID string `json:"id"`
Name string `json:"name"`
TimezoneID string `json:"timezone_id"`
MessageTemplateNamespace string `json:"message_template_namespace,omitempty"`
}