diff --git a/README.md b/README.md index 93eec9c..3143fa2 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ A Go library and service that connects to WhatsApp and forwards messages to regi ## 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 - [AI Usage Guidelines](AI_USE.md) - Rules when using AI tools with this project - [Project Plan](PLAN.md) - Development plan and architecture decisions diff --git a/WHATSAPP_BUSINESS.md b/WHATSAPP_BUSINESS.md new file mode 100644 index 0000000..735bd2d --- /dev/null +++ b/WHATSAPP_BUSINESS.md @@ -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 diff --git a/pkg/whatsapp/businessapi/client.go b/pkg/whatsapp/businessapi/client.go index 1e82dec..691be7b 100644 --- a/pkg/whatsapp/businessapi/client.go +++ b/pkg/whatsapp/businessapi/client.go @@ -68,38 +68,87 @@ func NewClient(cfg config.WhatsAppConfig, eventBus *events.EventBus, mediaConfig // 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) + logging.Info("Validating WhatsApp Business API credentials", "account_id", c.id) - 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) + // Step 1: Validate token and check permissions + tokenInfo, err := c.validateToken(ctx) if err != nil { 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 { - body, _ := io.ReadAll(resp.Body) - err := fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(body)) + // Log token information + logging.Info("Access token validated", + "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)) 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.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)) + logging.Info("Business API client connected successfully", + "account_id", c.id, + "phone", phoneDetails.DisplayPhoneNumber, + "verified_name", phoneDetails.VerifiedName) + c.eventBus.Publish(events.WhatsAppConnectedEvent(ctx, c.id, phoneDetails.DisplayPhoneNumber)) return nil } @@ -340,3 +389,154 @@ func jidToPhoneNumber(jid types.JID) string { 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") +} diff --git a/pkg/whatsapp/businessapi/types.go b/pkg/whatsapp/businessapi/types.go index 2b127ca..4afea73 100644 --- a/pkg/whatsapp/businessapi/types.go +++ b/pkg/whatsapp/businessapi/types.go @@ -191,3 +191,45 @@ type WebhookError struct { Code int `json:"code"` 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"` +}