Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3901bbb668 | ||
| 147dac9b60 | |||
| d80a6433b9 | |||
| 7b2390cbf6 | |||
| eb788f903a | |||
| 4d083b0bd9 |
5
.github/workflows/ci.yml
vendored
5
.github/workflows/ci.yml
vendored
@@ -34,6 +34,11 @@ jobs:
|
|||||||
- name: Download dependencies
|
- name: Download dependencies
|
||||||
run: go mod download
|
run: go mod download
|
||||||
|
|
||||||
|
- name: Clean build cache
|
||||||
|
run: |
|
||||||
|
go clean -cache
|
||||||
|
go mod tidy
|
||||||
|
|
||||||
- name: Run unit tests
|
- name: Run unit tests
|
||||||
run: make test
|
run: make test
|
||||||
|
|
||||||
|
|||||||
@@ -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
281
WHATSAPP_BUSINESS.md
Normal 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
|
||||||
8
go.mod
8
go.mod
@@ -30,20 +30,20 @@ require (
|
|||||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
github.com/petermattis/goid v0.0.0-20251121121749-a11dd1a45f9a // indirect
|
github.com/petermattis/goid v0.0.0-20251121121749-a11dd1a45f9a // indirect
|
||||||
github.com/rs/zerolog v1.34.0 // indirect
|
github.com/rs/zerolog v1.34.0 // indirect
|
||||||
github.com/sagikazarmark/locafero v0.11.0 // indirect
|
github.com/sagikazarmark/locafero v0.12.0 // indirect
|
||||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
|
||||||
github.com/spf13/afero v1.15.0 // indirect
|
github.com/spf13/afero v1.15.0 // indirect
|
||||||
github.com/spf13/cast v1.10.0 // indirect
|
github.com/spf13/cast v1.10.0 // indirect
|
||||||
github.com/spf13/pflag v1.0.10 // indirect
|
github.com/spf13/pflag v1.0.10 // indirect
|
||||||
github.com/subosito/gotenv v1.6.0 // indirect
|
github.com/subosito/gotenv v1.6.0 // indirect
|
||||||
github.com/vektah/gqlparser/v2 v2.5.27 // indirect
|
github.com/vektah/gqlparser/v2 v2.5.31 // indirect
|
||||||
go.mau.fi/libsignal v0.2.1 // indirect
|
go.mau.fi/libsignal v0.2.1 // indirect
|
||||||
go.mau.fi/util v0.9.4 // indirect
|
go.mau.fi/util v0.9.4 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 // indirect
|
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 // indirect
|
||||||
golang.org/x/net v0.48.0 // indirect
|
golang.org/x/net v0.48.0 // indirect
|
||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.39.0 // indirect
|
golang.org/x/sys v0.39.0 // indirect
|
||||||
golang.org/x/term v0.38.0 // indirect
|
golang.org/x/term v0.38.0 // indirect
|
||||||
golang.org/x/text v0.32.0 // indirect
|
golang.org/x/text v0.32.0 // indirect
|
||||||
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
14
go.sum
14
go.sum
@@ -63,12 +63,10 @@ github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
|||||||
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
||||||
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
|
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
|
||||||
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
|
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
|
||||||
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
|
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
|
||||||
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
|
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
|
||||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
|
|
||||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
|
|
||||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||||
@@ -84,8 +82,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
|||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||||
github.com/vektah/gqlparser/v2 v2.5.27 h1:RHPD3JOplpk5mP5JGX8RKZkt2/Vwj/PZv0HxTdwFp0s=
|
github.com/vektah/gqlparser/v2 v2.5.31 h1:YhWGA1mfTjID7qJhd1+Vxhpk5HTgydrGU9IgkWBTJ7k=
|
||||||
github.com/vektah/gqlparser/v2 v2.5.27/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo=
|
github.com/vektah/gqlparser/v2 v2.5.31/go.mod h1:c1I28gSOVNzlfc4WuDlqU7voQnsqI6OG2amkBAFmgts=
|
||||||
go.mau.fi/libsignal v0.2.1 h1:vRZG4EzTn70XY6Oh/pVKrQGuMHBkAWlGRC22/85m9L0=
|
go.mau.fi/libsignal v0.2.1 h1:vRZG4EzTn70XY6Oh/pVKrQGuMHBkAWlGRC22/85m9L0=
|
||||||
go.mau.fi/libsignal v0.2.1/go.mod h1:iVvjrHyfQqWajOUaMEsIfo3IqgVMrhWcPiiEzk7NgoU=
|
go.mau.fi/libsignal v0.2.1/go.mod h1:iVvjrHyfQqWajOUaMEsIfo3IqgVMrhWcPiiEzk7NgoU=
|
||||||
go.mau.fi/util v0.9.4 h1:gWdUff+K2rCynRPysXalqqQyr2ahkSWaestH6YhSpso=
|
go.mau.fi/util v0.9.4 h1:gWdUff+K2rCynRPysXalqqQyr2ahkSWaestH6YhSpso=
|
||||||
@@ -96,8 +94,8 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
|||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||||
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||||
golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 h1:MDfG8Cvcqlt9XXrmEiD4epKn7VJHZO84hejP9Jmp0MM=
|
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0=
|
||||||
golang.org/x/exp v0.0.0-20251209150349-8475f28825e9/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
|
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
|
||||||
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ type WhatsAppManager interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewLogger creates a new event logger
|
// NewLogger creates a new event logger
|
||||||
func NewLogger(cfg config.EventLoggerConfig, dbConfig config.DatabaseConfig, waManager WhatsAppManager) (*Logger, error) {
|
func NewLogger(cfg config.EventLoggerConfig, dbConfig config.DatabaseConfig, waManager WhatsAppManager, defaultCountryCode string) (*Logger, error) {
|
||||||
logger := &Logger{
|
logger := &Logger{
|
||||||
config: cfg,
|
config: cfg,
|
||||||
dbConfig: dbConfig,
|
dbConfig: dbConfig,
|
||||||
@@ -73,7 +73,8 @@ func NewLogger(cfg config.EventLoggerConfig, dbConfig config.DatabaseConfig, waM
|
|||||||
logging.Info("Event logger PostgreSQL target initialized")
|
logging.Info("Event logger PostgreSQL target initialized")
|
||||||
|
|
||||||
case "mqtt":
|
case "mqtt":
|
||||||
mqttTarget, err := NewMQTTTarget(cfg.MQTT, waManager)
|
logging.Info("Initializing MQTT event logger target", "broker", cfg.MQTT.Broker)
|
||||||
|
mqttTarget, err := NewMQTTTarget(cfg.MQTT, waManager, defaultCountryCode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logging.Error("Failed to initialize MQTT target", "error", err)
|
logging.Error("Failed to initialize MQTT target", "error", err)
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -17,14 +17,15 @@ import (
|
|||||||
|
|
||||||
// MQTTTarget represents an MQTT logging target
|
// MQTTTarget represents an MQTT logging target
|
||||||
type MQTTTarget struct {
|
type MQTTTarget struct {
|
||||||
client mqtt.Client
|
client mqtt.Client
|
||||||
config config.MQTTConfig
|
config config.MQTTConfig
|
||||||
waManager WhatsAppManager
|
waManager WhatsAppManager
|
||||||
eventFilter map[string]bool
|
eventFilter map[string]bool
|
||||||
|
defaultCountryCode string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewMQTTTarget creates a new MQTT target
|
// NewMQTTTarget creates a new MQTT target
|
||||||
func NewMQTTTarget(cfg config.MQTTConfig, waManager WhatsAppManager) (*MQTTTarget, error) {
|
func NewMQTTTarget(cfg config.MQTTConfig, waManager WhatsAppManager, defaultCountryCode string) (*MQTTTarget, error) {
|
||||||
if cfg.Broker == "" {
|
if cfg.Broker == "" {
|
||||||
return nil, fmt.Errorf("MQTT broker is required")
|
return nil, fmt.Errorf("MQTT broker is required")
|
||||||
}
|
}
|
||||||
@@ -41,9 +42,10 @@ func NewMQTTTarget(cfg config.MQTTConfig, waManager WhatsAppManager) (*MQTTTarge
|
|||||||
}
|
}
|
||||||
|
|
||||||
target := &MQTTTarget{
|
target := &MQTTTarget{
|
||||||
config: cfg,
|
config: cfg,
|
||||||
waManager: waManager,
|
waManager: waManager,
|
||||||
eventFilter: make(map[string]bool),
|
eventFilter: make(map[string]bool),
|
||||||
|
defaultCountryCode: defaultCountryCode,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build event filter map for fast lookup
|
// Build event filter map for fast lookup
|
||||||
@@ -82,16 +84,18 @@ func NewMQTTTarget(cfg config.MQTTConfig, waManager WhatsAppManager) (*MQTTTarge
|
|||||||
if cfg.Subscribe {
|
if cfg.Subscribe {
|
||||||
// Subscribe to send command topic for all accounts
|
// Subscribe to send command topic for all accounts
|
||||||
topic := fmt.Sprintf("%s/+/send", cfg.TopicPrefix)
|
topic := fmt.Sprintf("%s/+/send", cfg.TopicPrefix)
|
||||||
|
logging.Info("Starting MQTT subscription", "topic", topic, "qos", cfg.QoS)
|
||||||
if token := client.Subscribe(topic, byte(cfg.QoS), target.handleSendMessage); token.Wait() && token.Error() != nil {
|
if token := client.Subscribe(topic, byte(cfg.QoS), target.handleSendMessage); token.Wait() && token.Error() != nil {
|
||||||
logging.Error("Failed to subscribe to MQTT topic", "topic", topic, "error", token.Error())
|
logging.Error("Failed to subscribe to MQTT topic", "topic", topic, "error", token.Error())
|
||||||
} else {
|
} else {
|
||||||
logging.Info("Subscribed to MQTT send topic", "topic", topic)
|
logging.Info("Successfully subscribed to MQTT send topic", "topic", topic)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Create and connect the client
|
// Create and connect the client
|
||||||
client := mqtt.NewClient(opts)
|
client := mqtt.NewClient(opts)
|
||||||
|
logging.Info("Starting MQTT connection", "broker", cfg.Broker, "client_id", cfg.ClientID)
|
||||||
if token := client.Connect(); token.Wait() && token.Error() != nil {
|
if token := client.Connect(); token.Wait() && token.Error() != nil {
|
||||||
return nil, fmt.Errorf("failed to connect to MQTT broker: %w", token.Error())
|
return nil, fmt.Errorf("failed to connect to MQTT broker: %w", token.Error())
|
||||||
}
|
}
|
||||||
@@ -181,10 +185,13 @@ func (m *MQTTTarget) handleSendMessage(client mqtt.Client, msg mqtt.Message) {
|
|||||||
sendReq.Type = "text"
|
sendReq.Type = "text"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Format phone number to JID format
|
||||||
|
formattedJID := utils.FormatPhoneToJID(sendReq.To, m.defaultCountryCode)
|
||||||
|
|
||||||
// Parse JID
|
// Parse JID
|
||||||
jid, err := types.ParseJID(sendReq.To)
|
jid, err := types.ParseJID(formattedJID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logging.Error("Failed to parse JID", "to", sendReq.To, "error", err)
|
logging.Error("Failed to parse JID", "to", sendReq.To, "formatted", formattedJID, "error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -90,20 +90,26 @@ func (m *Manager) Start() {
|
|||||||
for _, eventType := range allEventTypes {
|
for _, eventType := range allEventTypes {
|
||||||
m.eventBus.Subscribe(eventType, m.handleEvent)
|
m.eventBus.Subscribe(eventType, m.handleEvent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logging.Info("Hook manager started and subscribed to events", "event_types", len(allEventTypes))
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleEvent processes any event and triggers relevant hooks
|
// handleEvent processes any event and triggers relevant hooks
|
||||||
func (m *Manager) handleEvent(event events.Event) {
|
func (m *Manager) handleEvent(event events.Event) {
|
||||||
|
logging.Debug("Hook manager received event", "event_type", event.Type)
|
||||||
|
|
||||||
// Get hooks that are subscribed to this event type
|
// Get hooks that are subscribed to this event type
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
relevantHooks := make([]config.Hook, 0)
|
relevantHooks := make([]config.Hook, 0)
|
||||||
for _, hook := range m.hooks {
|
for _, hook := range m.hooks {
|
||||||
if !hook.Active {
|
if !hook.Active {
|
||||||
|
logging.Debug("Skipping inactive hook", "hook_id", hook.ID)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// If hook has no events specified, subscribe to all events
|
// If hook has no events specified, subscribe to all events
|
||||||
if len(hook.Events) == 0 {
|
if len(hook.Events) == 0 {
|
||||||
|
logging.Debug("Hook subscribes to all events", "hook_id", hook.ID)
|
||||||
relevantHooks = append(relevantHooks, hook)
|
relevantHooks = append(relevantHooks, hook)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -112,6 +118,7 @@ func (m *Manager) handleEvent(event events.Event) {
|
|||||||
eventTypeStr := string(event.Type)
|
eventTypeStr := string(event.Type)
|
||||||
for _, subscribedEvent := range hook.Events {
|
for _, subscribedEvent := range hook.Events {
|
||||||
if subscribedEvent == eventTypeStr {
|
if subscribedEvent == eventTypeStr {
|
||||||
|
logging.Debug("Hook matches event", "hook_id", hook.ID, "event_type", eventTypeStr)
|
||||||
relevantHooks = append(relevantHooks, hook)
|
relevantHooks = append(relevantHooks, hook)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -119,6 +126,8 @@ func (m *Manager) handleEvent(event events.Event) {
|
|||||||
}
|
}
|
||||||
m.mu.RUnlock()
|
m.mu.RUnlock()
|
||||||
|
|
||||||
|
logging.Debug("Found relevant hooks for event", "event_type", event.Type, "hook_count", len(relevantHooks))
|
||||||
|
|
||||||
// Trigger each relevant hook
|
// Trigger each relevant hook
|
||||||
if len(relevantHooks) > 0 {
|
if len(relevantHooks) > 0 {
|
||||||
m.triggerHooksForEvent(event, relevantHooks)
|
m.triggerHooksForEvent(event, relevantHooks)
|
||||||
@@ -265,17 +274,24 @@ func (m *Manager) ListHooks() []config.Hook {
|
|||||||
|
|
||||||
// sendToHook sends any payload to a specific hook with explicit event type
|
// sendToHook sends any payload to a specific hook with explicit event type
|
||||||
func (m *Manager) sendToHook(ctx context.Context, hook config.Hook, payload interface{}, eventType events.EventType) *HookResponse {
|
func (m *Manager) sendToHook(ctx context.Context, hook config.Hook, payload interface{}, eventType events.EventType) *HookResponse {
|
||||||
if ctx == nil {
|
// Create a new context detached from the incoming context to prevent cancellation
|
||||||
ctx = context.Background()
|
// when the original HTTP request completes. Use a 30-second timeout to match client timeout.
|
||||||
|
hookCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Use the original context for event publishing (if available)
|
||||||
|
eventCtx := ctx
|
||||||
|
if eventCtx == nil {
|
||||||
|
eventCtx = context.Background()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Publish hook triggered event
|
// Publish hook triggered event
|
||||||
m.eventBus.Publish(events.HookTriggeredEvent(ctx, hook.ID, hook.Name, hook.URL, payload))
|
m.eventBus.Publish(events.HookTriggeredEvent(eventCtx, hook.ID, hook.Name, hook.URL, payload))
|
||||||
|
|
||||||
data, err := json.Marshal(payload)
|
data, err := json.Marshal(payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logging.Error("Failed to marshal payload", "hook_id", hook.ID, "error", err)
|
logging.Error("Failed to marshal payload", "hook_id", hook.ID, "error", err)
|
||||||
m.eventBus.Publish(events.HookFailedEvent(ctx, hook.ID, hook.Name, err))
|
m.eventBus.Publish(events.HookFailedEvent(eventCtx, hook.ID, hook.Name, err))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,7 +299,7 @@ func (m *Manager) sendToHook(ctx context.Context, hook config.Hook, payload inte
|
|||||||
parsedURL, err := url.Parse(hook.URL)
|
parsedURL, err := url.Parse(hook.URL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logging.Error("Failed to parse hook URL", "hook_id", hook.ID, "error", err)
|
logging.Error("Failed to parse hook URL", "hook_id", hook.ID, "error", err)
|
||||||
m.eventBus.Publish(events.HookFailedEvent(ctx, hook.ID, hook.Name, err))
|
m.eventBus.Publish(events.HookFailedEvent(eventCtx, hook.ID, hook.Name, err))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,10 +327,10 @@ func (m *Manager) sendToHook(ctx context.Context, hook config.Hook, payload inte
|
|||||||
}
|
}
|
||||||
parsedURL.RawQuery = query.Encode()
|
parsedURL.RawQuery = query.Encode()
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, hook.Method, parsedURL.String(), bytes.NewReader(data))
|
req, err := http.NewRequestWithContext(hookCtx, hook.Method, parsedURL.String(), bytes.NewReader(data))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logging.Error("Failed to create request", "hook_id", hook.ID, "error", err)
|
logging.Error("Failed to create request", "hook_id", hook.ID, "error", err)
|
||||||
m.eventBus.Publish(events.HookFailedEvent(ctx, hook.ID, hook.Name, err))
|
m.eventBus.Publish(events.HookFailedEvent(eventCtx, hook.ID, hook.Name, err))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -328,14 +344,14 @@ func (m *Manager) sendToHook(ctx context.Context, hook config.Hook, payload inte
|
|||||||
resp, err := m.client.Do(req)
|
resp, err := m.client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logging.Error("Failed to send to hook", "hook_id", hook.ID, "error", err)
|
logging.Error("Failed to send to hook", "hook_id", hook.ID, "error", err)
|
||||||
m.eventBus.Publish(events.HookFailedEvent(ctx, hook.ID, hook.Name, err))
|
m.eventBus.Publish(events.HookFailedEvent(eventCtx, hook.ID, hook.Name, err))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
logging.Warn("Hook returned non-success status", "hook_id", hook.ID, "status", resp.StatusCode)
|
logging.Warn("Hook returned non-success status", "hook_id", hook.ID, "status", resp.StatusCode)
|
||||||
m.eventBus.Publish(events.HookFailedEvent(ctx, hook.ID, hook.Name, fmt.Errorf("status code %d", resp.StatusCode)))
|
m.eventBus.Publish(events.HookFailedEvent(eventCtx, hook.ID, hook.Name, fmt.Errorf("status code %d", resp.StatusCode)))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,23 +359,23 @@ func (m *Manager) sendToHook(ctx context.Context, hook config.Hook, payload inte
|
|||||||
body, err := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logging.Error("Failed to read hook response", "hook_id", hook.ID, "error", err)
|
logging.Error("Failed to read hook response", "hook_id", hook.ID, "error", err)
|
||||||
m.eventBus.Publish(events.HookFailedEvent(ctx, hook.ID, hook.Name, err))
|
m.eventBus.Publish(events.HookFailedEvent(eventCtx, hook.ID, hook.Name, err))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(body) == 0 {
|
if len(body) == 0 {
|
||||||
m.eventBus.Publish(events.HookSuccessEvent(ctx, hook.ID, hook.Name, resp.StatusCode, nil))
|
m.eventBus.Publish(events.HookSuccessEvent(eventCtx, hook.ID, hook.Name, resp.StatusCode, nil))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var hookResp HookResponse
|
var hookResp HookResponse
|
||||||
if err := json.Unmarshal(body, &hookResp); err != nil {
|
if err := json.Unmarshal(body, &hookResp); err != nil {
|
||||||
logging.Debug("Hook response not JSON", "hook_id", hook.ID)
|
logging.Debug("Hook response not JSON", "hook_id", hook.ID)
|
||||||
m.eventBus.Publish(events.HookSuccessEvent(ctx, hook.ID, hook.Name, resp.StatusCode, string(body)))
|
m.eventBus.Publish(events.HookSuccessEvent(eventCtx, hook.ID, hook.Name, resp.StatusCode, string(body)))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
logging.Debug("Hook response received", "hook_id", hook.ID, "send_message", hookResp.SendMessage)
|
logging.Debug("Hook response received", "hook_id", hook.ID, "send_message", hookResp.SendMessage)
|
||||||
m.eventBus.Publish(events.HookSuccessEvent(ctx, hook.ID, hook.Name, resp.StatusCode, hookResp))
|
m.eventBus.Publish(events.HookSuccessEvent(eventCtx, hook.ID, hook.Name, resp.StatusCode, hookResp))
|
||||||
return &hookResp
|
return &hookResp
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.warky.dev/wdevs/whatshooked/pkg/events"
|
"git.warky.dev/wdevs/whatshooked/pkg/events"
|
||||||
@@ -44,14 +45,53 @@ func (c *Client) HandleWebhook(r *http.Request) error {
|
|||||||
func (c *Client) processChange(change WebhookChange) {
|
func (c *Client) processChange(change WebhookChange) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// Process messages
|
// Handle different field types
|
||||||
for _, msg := range change.Value.Messages {
|
switch change.Field {
|
||||||
c.processMessage(ctx, msg, change.Value.Contacts)
|
case "messages":
|
||||||
}
|
// Process messages
|
||||||
|
for _, msg := range change.Value.Messages {
|
||||||
|
c.processMessage(ctx, msg, change.Value.Contacts)
|
||||||
|
}
|
||||||
|
|
||||||
// Process statuses
|
// Process statuses
|
||||||
for _, status := range change.Value.Statuses {
|
for _, status := range change.Value.Statuses {
|
||||||
c.processStatus(ctx, status)
|
c.processStatus(ctx, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
case "message_template_status_update":
|
||||||
|
// Log template status updates for visibility
|
||||||
|
logging.Info("Message template status update received",
|
||||||
|
"account_id", c.id,
|
||||||
|
"phone_number_id", change.Value.Metadata.PhoneNumberID)
|
||||||
|
|
||||||
|
case "account_update":
|
||||||
|
// Log account updates
|
||||||
|
logging.Info("Account update received",
|
||||||
|
"account_id", c.id,
|
||||||
|
"phone_number_id", change.Value.Metadata.PhoneNumberID)
|
||||||
|
|
||||||
|
case "phone_number_quality_update":
|
||||||
|
// Log quality updates
|
||||||
|
logging.Info("Phone number quality update received",
|
||||||
|
"account_id", c.id,
|
||||||
|
"phone_number_id", change.Value.Metadata.PhoneNumberID)
|
||||||
|
|
||||||
|
case "phone_number_name_update":
|
||||||
|
// Log name updates
|
||||||
|
logging.Info("Phone number name update received",
|
||||||
|
"account_id", c.id,
|
||||||
|
"phone_number_id", change.Value.Metadata.PhoneNumberID)
|
||||||
|
|
||||||
|
case "account_alerts":
|
||||||
|
// Log account alerts
|
||||||
|
logging.Warn("Account alert received",
|
||||||
|
"account_id", c.id,
|
||||||
|
"phone_number_id", change.Value.Metadata.PhoneNumberID)
|
||||||
|
|
||||||
|
default:
|
||||||
|
logging.Debug("Unknown webhook field type",
|
||||||
|
"account_id", c.id,
|
||||||
|
"field", change.Field)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,12 +170,114 @@ func (c *Client) processMessage(ctx context.Context, msg WebhookMessage, contact
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "audio":
|
||||||
|
if msg.Audio != nil {
|
||||||
|
messageType = "audio"
|
||||||
|
mimeType = msg.Audio.MimeType
|
||||||
|
|
||||||
|
// Download and process media
|
||||||
|
data, _, err := c.downloadMedia(ctx, msg.Audio.ID)
|
||||||
|
if err != nil {
|
||||||
|
logging.Error("Failed to download audio", "account_id", c.id, "media_id", msg.Audio.ID, "error", err)
|
||||||
|
} else {
|
||||||
|
filename, mediaURL = c.processMediaData(msg.ID, data, mimeType, &mediaBase64)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case "sticker":
|
||||||
|
if msg.Sticker != nil {
|
||||||
|
messageType = "sticker"
|
||||||
|
mimeType = msg.Sticker.MimeType
|
||||||
|
|
||||||
|
// Download and process media
|
||||||
|
data, _, err := c.downloadMedia(ctx, msg.Sticker.ID)
|
||||||
|
if err != nil {
|
||||||
|
logging.Error("Failed to download sticker", "account_id", c.id, "media_id", msg.Sticker.ID, "error", err)
|
||||||
|
} else {
|
||||||
|
filename, mediaURL = c.processMediaData(msg.ID, data, mimeType, &mediaBase64)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case "location":
|
||||||
|
if msg.Location != nil {
|
||||||
|
messageType = "location"
|
||||||
|
// Format location as text
|
||||||
|
text = fmt.Sprintf("Location: %s (%s) - %.6f, %.6f",
|
||||||
|
msg.Location.Name, msg.Location.Address,
|
||||||
|
msg.Location.Latitude, msg.Location.Longitude)
|
||||||
|
}
|
||||||
|
|
||||||
|
case "contacts":
|
||||||
|
if len(msg.Contacts) > 0 {
|
||||||
|
messageType = "contacts"
|
||||||
|
// Format contacts as text
|
||||||
|
var contactNames []string
|
||||||
|
for _, contact := range msg.Contacts {
|
||||||
|
contactNames = append(contactNames, contact.Name.FormattedName)
|
||||||
|
}
|
||||||
|
text = fmt.Sprintf("Shared %d contact(s): %s", len(msg.Contacts), strings.Join(contactNames, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
case "interactive":
|
||||||
|
if msg.Interactive != nil {
|
||||||
|
messageType = "interactive"
|
||||||
|
switch msg.Interactive.Type {
|
||||||
|
case "button_reply":
|
||||||
|
if msg.Interactive.ButtonReply != nil {
|
||||||
|
text = msg.Interactive.ButtonReply.Title
|
||||||
|
}
|
||||||
|
case "list_reply":
|
||||||
|
if msg.Interactive.ListReply != nil {
|
||||||
|
text = msg.Interactive.ListReply.Title
|
||||||
|
}
|
||||||
|
case "nfm_reply":
|
||||||
|
if msg.Interactive.NfmReply != nil {
|
||||||
|
text = msg.Interactive.NfmReply.Body
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case "button":
|
||||||
|
if msg.Button != nil {
|
||||||
|
messageType = "button"
|
||||||
|
text = msg.Button.Text
|
||||||
|
}
|
||||||
|
|
||||||
|
case "reaction":
|
||||||
|
if msg.Reaction != nil {
|
||||||
|
messageType = "reaction"
|
||||||
|
text = msg.Reaction.Emoji
|
||||||
|
}
|
||||||
|
|
||||||
|
case "order":
|
||||||
|
if msg.Order != nil {
|
||||||
|
messageType = "order"
|
||||||
|
text = fmt.Sprintf("Order with %d item(s): %s", len(msg.Order.ProductItems), msg.Order.Text)
|
||||||
|
}
|
||||||
|
|
||||||
|
case "system":
|
||||||
|
if msg.System != nil {
|
||||||
|
messageType = "system"
|
||||||
|
text = msg.System.Body
|
||||||
|
}
|
||||||
|
|
||||||
|
case "unknown":
|
||||||
|
messageType = "unknown"
|
||||||
|
logging.Warn("Received unknown message type", "account_id", c.id, "message_id", msg.ID)
|
||||||
|
return
|
||||||
|
|
||||||
default:
|
default:
|
||||||
logging.Warn("Unsupported message type", "account_id", c.id, "type", msg.Type)
|
logging.Warn("Unsupported message type", "account_id", c.id, "type", msg.Type)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Publish message received event
|
// Publish message received event
|
||||||
|
logging.Debug("Publishing message received event",
|
||||||
|
"account_id", c.id,
|
||||||
|
"message_id", msg.ID,
|
||||||
|
"from", msg.From,
|
||||||
|
"type", messageType)
|
||||||
|
|
||||||
c.eventBus.Publish(events.MessageReceivedEvent(
|
c.eventBus.Publish(events.MessageReceivedEvent(
|
||||||
ctx,
|
ctx,
|
||||||
c.id,
|
c.id,
|
||||||
@@ -276,7 +418,10 @@ func getExtensionFromMimeType(mimeType string) string {
|
|||||||
"text/plain": ".txt",
|
"text/plain": ".txt",
|
||||||
"application/json": ".json",
|
"application/json": ".json",
|
||||||
"audio/mpeg": ".mp3",
|
"audio/mpeg": ".mp3",
|
||||||
|
"audio/mp4": ".m4a",
|
||||||
"audio/ogg": ".ogg",
|
"audio/ogg": ".ogg",
|
||||||
|
"audio/amr": ".amr",
|
||||||
|
"audio/opus": ".opus",
|
||||||
}
|
}
|
||||||
|
|
||||||
if ext, ok := extensions[mimeType]; ok {
|
if ext, ok := extensions[mimeType]; ok {
|
||||||
|
|||||||
@@ -116,15 +116,26 @@ type WebhookProfile struct {
|
|||||||
|
|
||||||
// WebhookMessage represents a message in the webhook
|
// WebhookMessage represents a message in the webhook
|
||||||
type WebhookMessage struct {
|
type WebhookMessage struct {
|
||||||
From string `json:"from"` // Sender phone number
|
From string `json:"from"` // Sender phone number
|
||||||
ID string `json:"id"` // Message ID
|
ID string `json:"id"` // Message ID
|
||||||
Timestamp string `json:"timestamp"` // Unix timestamp as string
|
Timestamp string `json:"timestamp"` // Unix timestamp as string
|
||||||
Type string `json:"type"` // "text", "image", "video", "document", etc.
|
Type string `json:"type"` // "text", "image", "video", "document", "audio", "sticker", "location", "contacts", "interactive", "button", "order", "system", "unknown", "reaction"
|
||||||
Text *WebhookText `json:"text,omitempty"`
|
Text *WebhookText `json:"text,omitempty"`
|
||||||
Image *WebhookMediaMessage `json:"image,omitempty"`
|
Image *WebhookMediaMessage `json:"image,omitempty"`
|
||||||
Video *WebhookMediaMessage `json:"video,omitempty"`
|
Video *WebhookMediaMessage `json:"video,omitempty"`
|
||||||
Document *WebhookDocumentMessage `json:"document,omitempty"`
|
Document *WebhookDocumentMessage `json:"document,omitempty"`
|
||||||
Context *WebhookContext `json:"context,omitempty"` // Reply context
|
Audio *WebhookMediaMessage `json:"audio,omitempty"`
|
||||||
|
Sticker *WebhookMediaMessage `json:"sticker,omitempty"`
|
||||||
|
Location *WebhookLocation `json:"location,omitempty"`
|
||||||
|
Contacts []WebhookContactCard `json:"contacts,omitempty"`
|
||||||
|
Interactive *WebhookInteractive `json:"interactive,omitempty"`
|
||||||
|
Button *WebhookButton `json:"button,omitempty"`
|
||||||
|
Reaction *WebhookReaction `json:"reaction,omitempty"`
|
||||||
|
Order *WebhookOrder `json:"order,omitempty"`
|
||||||
|
System *WebhookSystem `json:"system,omitempty"`
|
||||||
|
Context *WebhookContext `json:"context,omitempty"` // Reply context
|
||||||
|
Identity *WebhookIdentity `json:"identity,omitempty"`
|
||||||
|
Referral *WebhookReferral `json:"referral,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// WebhookText represents a text message
|
// WebhookText represents a text message
|
||||||
@@ -156,6 +167,156 @@ type WebhookContext struct {
|
|||||||
MessageID string `json:"message_id,omitempty"`
|
MessageID string `json:"message_id,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WebhookLocation represents a location message
|
||||||
|
type WebhookLocation struct {
|
||||||
|
Latitude float64 `json:"latitude"`
|
||||||
|
Longitude float64 `json:"longitude"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Address string `json:"address,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebhookContactCard represents a contact card
|
||||||
|
type WebhookContactCard struct {
|
||||||
|
Addresses []WebhookContactAddress `json:"addresses,omitempty"`
|
||||||
|
Birthday string `json:"birthday,omitempty"`
|
||||||
|
Emails []WebhookContactEmail `json:"emails,omitempty"`
|
||||||
|
Name WebhookContactName `json:"name"`
|
||||||
|
Org WebhookContactOrg `json:"org,omitempty"`
|
||||||
|
Phones []WebhookContactPhone `json:"phones,omitempty"`
|
||||||
|
URLs []WebhookContactURL `json:"urls,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebhookContactAddress represents a contact address
|
||||||
|
type WebhookContactAddress struct {
|
||||||
|
City string `json:"city,omitempty"`
|
||||||
|
Country string `json:"country,omitempty"`
|
||||||
|
CountryCode string `json:"country_code,omitempty"`
|
||||||
|
State string `json:"state,omitempty"`
|
||||||
|
Street string `json:"street,omitempty"`
|
||||||
|
Type string `json:"type,omitempty"`
|
||||||
|
Zip string `json:"zip,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebhookContactEmail represents a contact email
|
||||||
|
type WebhookContactEmail struct {
|
||||||
|
Email string `json:"email,omitempty"`
|
||||||
|
Type string `json:"type,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebhookContactName represents a contact name
|
||||||
|
type WebhookContactName struct {
|
||||||
|
FormattedName string `json:"formatted_name"`
|
||||||
|
FirstName string `json:"first_name,omitempty"`
|
||||||
|
LastName string `json:"last_name,omitempty"`
|
||||||
|
MiddleName string `json:"middle_name,omitempty"`
|
||||||
|
Suffix string `json:"suffix,omitempty"`
|
||||||
|
Prefix string `json:"prefix,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebhookContactOrg represents a contact organization
|
||||||
|
type WebhookContactOrg struct {
|
||||||
|
Company string `json:"company,omitempty"`
|
||||||
|
Department string `json:"department,omitempty"`
|
||||||
|
Title string `json:"title,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebhookContactPhone represents a contact phone
|
||||||
|
type WebhookContactPhone struct {
|
||||||
|
Phone string `json:"phone,omitempty"`
|
||||||
|
Type string `json:"type,omitempty"`
|
||||||
|
WaID string `json:"wa_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebhookContactURL represents a contact URL
|
||||||
|
type WebhookContactURL struct {
|
||||||
|
URL string `json:"url,omitempty"`
|
||||||
|
Type string `json:"type,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebhookInteractive represents an interactive message response
|
||||||
|
type WebhookInteractive struct {
|
||||||
|
Type string `json:"type"` // "button_reply", "list_reply"
|
||||||
|
ButtonReply *WebhookButtonReply `json:"button_reply,omitempty"`
|
||||||
|
ListReply *WebhookListReply `json:"list_reply,omitempty"`
|
||||||
|
NfmReply *WebhookNfmReply `json:"nfm_reply,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebhookButtonReply represents a button reply
|
||||||
|
type WebhookButtonReply struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebhookListReply represents a list reply
|
||||||
|
type WebhookListReply struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebhookNfmReply represents a native flow message reply
|
||||||
|
type WebhookNfmReply struct {
|
||||||
|
ResponseJSON string `json:"response_json"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebhookButton represents a quick reply button
|
||||||
|
type WebhookButton struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
Payload string `json:"payload"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebhookReaction represents a reaction to a message
|
||||||
|
type WebhookReaction struct {
|
||||||
|
MessageID string `json:"message_id"`
|
||||||
|
Emoji string `json:"emoji"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebhookOrder represents an order
|
||||||
|
type WebhookOrder struct {
|
||||||
|
CatalogID string `json:"catalog_id"`
|
||||||
|
ProductItems []WebhookProductItem `json:"product_items"`
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebhookProductItem represents a product in an order
|
||||||
|
type WebhookProductItem struct {
|
||||||
|
ProductRetailerID string `json:"product_retailer_id"`
|
||||||
|
Quantity int `json:"quantity"`
|
||||||
|
ItemPrice float64 `json:"item_price"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebhookSystem represents a system message
|
||||||
|
type WebhookSystem struct {
|
||||||
|
Body string `json:"body,omitempty"`
|
||||||
|
Type string `json:"type,omitempty"` // "customer_changed_number", "customer_identity_changed", etc.
|
||||||
|
Identity string `json:"identity,omitempty"`
|
||||||
|
NewWaID string `json:"new_wa_id,omitempty"`
|
||||||
|
WaID string `json:"wa_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebhookIdentity represents identity information
|
||||||
|
type WebhookIdentity struct {
|
||||||
|
Acknowledged bool `json:"acknowledged"`
|
||||||
|
CreatedTimestamp string `json:"created_timestamp"`
|
||||||
|
Hash string `json:"hash"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebhookReferral represents referral information
|
||||||
|
type WebhookReferral struct {
|
||||||
|
SourceURL string `json:"source_url"`
|
||||||
|
SourceID string `json:"source_id,omitempty"`
|
||||||
|
SourceType string `json:"source_type"`
|
||||||
|
Headline string `json:"headline,omitempty"`
|
||||||
|
Body string `json:"body,omitempty"`
|
||||||
|
MediaType string `json:"media_type,omitempty"`
|
||||||
|
ImageURL string `json:"image_url,omitempty"`
|
||||||
|
VideoURL string `json:"video_url,omitempty"`
|
||||||
|
ThumbnailURL string `json:"thumbnail_url,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// WebhookStatus represents a message status update
|
// WebhookStatus represents a message status update
|
||||||
type WebhookStatus struct {
|
type WebhookStatus struct {
|
||||||
ID string `json:"id"` // Message ID
|
ID string `json:"id"` // Message ID
|
||||||
@@ -191,3 +352,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"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ func newWithConfig(cfg *config.Config, configPath string) (*WhatsHooked, error)
|
|||||||
|
|
||||||
// Initialize event logger if enabled
|
// Initialize event logger if enabled
|
||||||
if cfg.EventLogger.Enabled && len(cfg.EventLogger.Targets) > 0 {
|
if cfg.EventLogger.Enabled && len(cfg.EventLogger.Targets) > 0 {
|
||||||
logger, err := eventlogger.NewLogger(cfg.EventLogger, cfg.Database, wh.whatsappMgr)
|
logger, err := eventlogger.NewLogger(cfg.EventLogger, cfg.Database, wh.whatsappMgr, cfg.Server.DefaultCountryCode)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
wh.eventLogger = logger
|
wh.eventLogger = logger
|
||||||
wh.eventBus.SubscribeAll(func(event events.Event) {
|
wh.eventBus.SubscribeAll(func(event events.Event) {
|
||||||
|
|||||||
Reference in New Issue
Block a user