Compare commits

6 Commits
v1.0.0 ... main

Author SHA1 Message Date
Hein
3901bbb668 feat(whatsapp): Enhance webhook message handling
Some checks failed
CI / Test (1.22) (push) Failing after -24m5s
CI / Test (1.23) (push) Failing after -23m59s
CI / Build (push) Successful in -24m25s
CI / Lint (push) Failing after -24m11s
* Add support for new message types: audio, sticker, location, contacts, interactive, button, reaction, order, system, and unknown.
* Implement logging for various webhook events for better visibility.
* Update WebhookMessage struct to include new fields for enhanced message processing.
2026-01-30 11:30:10 +02:00
147dac9b60 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
2025-12-30 11:35:10 +02:00
d80a6433b9 Fixed mqtt bug where phone number is not formatted
Some checks failed
CI / Test (1.23) (push) Failing after -22m52s
CI / Test (1.22) (push) Failing after -22m44s
CI / Build (push) Successful in -25m59s
CI / Lint (push) Successful in -25m47s
2025-12-30 01:00:42 +02:00
7b2390cbf6 Mqtt logging
Some checks failed
CI / Test (1.22) (push) Failing after -23m38s
CI / Test (1.23) (push) Failing after -23m28s
CI / Build (push) Successful in -25m46s
CI / Lint (push) Successful in -25m30s
2025-12-30 00:19:49 +02:00
eb788f903a update: deps
Some checks failed
CI / Test (1.22) (push) Failing after -22m58s
CI / Test (1.23) (push) Failing after -22m50s
CI / Lint (push) Successful in -23m30s
CI / Build (push) Successful in -23m41s
2025-12-29 23:42:23 +02:00
4d083b0bd9 Better tests
Some checks failed
CI / Test (1.23) (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Test (1.22) (push) Has been cancelled
2025-12-29 23:40:49 +02:00
12 changed files with 931 additions and 74 deletions

View File

@@ -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

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

8
go.mod
View File

@@ -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
View File

@@ -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=

View File

@@ -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

View File

@@ -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
} }

View File

@@ -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
} }

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

@@ -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 {

View File

@@ -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"`
}

View File

@@ -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) {