Compare commits

7 Commits
v1.0.0 ... main

Author SHA1 Message Date
Hein
c4d974d6ce feat(cache): 🎉 add message caching functionality
Some checks failed
CI / Test (1.23) (push) Failing after -27m1s
CI / Lint (push) Successful in -26m31s
CI / Build (push) Successful in -27m3s
CI / Test (1.22) (push) Failing after -24m58s
* Implement MessageCache to store events when no webhooks are available.
* Add configuration options for enabling cache, setting data path, max age, and max events.
* Create API endpoints for managing cached events, including listing, replaying, and deleting.
* Integrate caching into the hooks manager to store events when no active webhooks are found.
* Enhance logging for better traceability of cached events and operations.
2026-01-30 16:00:34 +02:00
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
17 changed files with 2461 additions and 99 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

937
WHATSAPP_BUSINESS.md Normal file
View File

@@ -0,0 +1,937 @@
# 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",
"verify_token": "your_secure_random_token_here"
}
}
]
}
```
### 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"`) |
| `verify_token` | Yes | Random string for webhook verification (see Step 8a) |
### Step 8a: Generate Verify Token
The verify token is used by Meta to verify your webhook endpoint. Generate a secure random string:
```bash
# Generate a random token
openssl rand -hex 32
# Or use any secure random string like:
# "my_secure_verify_token_abc123xyz789"
```
Add this token to your `config.json` (see above) and save it - you'll need it for webhook configuration in Step 10.
## Step 9: Start WhatsHooked
```bash
./bin/whatshook-server -config config.json
```
You should see:
```
INFO Business API client connected account_id=business phone=+1234567890
INFO Hook manager started and subscribed to events event_types=13
```
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
## Step 10: Configure Webhook in Meta Developer Console
WhatsHooked provides a webhook endpoint to receive incoming messages and status updates from WhatsApp.
### 10.1: Webhook URL Format
Your webhook URL should be:
```
https://your-domain.com/webhooks/whatsapp/{account_id}
```
Where `{account_id}` matches the `id` field in your config (e.g., "business").
**Example**: If your domain is `api.example.com` and account ID is `business`:
```
https://api.example.com/webhooks/whatsapp/business
```
### 10.2: Configure in Meta Developer Console
1. Go to [Meta Developers](https://developers.facebook.com/)
2. Select your app
3. Navigate to **WhatsApp****Configuration**
4. Under "Webhook", click **Edit**
5. Enter:
- **Callback URL**: `https://your-domain.com/webhooks/whatsapp/business`
- **Verify Token**: The same token from your `config.json` (`verify_token` field)
6. Click **Verify and Save**
Meta will send a GET request to verify your endpoint. If verification succeeds, you'll see a green checkmark.
### 10.3: Subscribe to Webhook Events
After verification, subscribe to these webhook fields:
-**messages** - Incoming messages and message status updates
-**message_template_status_update** - Template approval/rejection (optional)
-**account_update** - Account changes (optional)
-**phone_number_quality_update** - Quality rating changes (optional)
Click **Subscribe** for each field you want to receive.
## Supported Webhook Events
WhatsHooked supports all WhatsApp Business API webhook events and message types:
### Message Types
| Type | Supported | Downloads Media | Description |
|------|-----------|-----------------|-------------|
| `text` | ✅ | N/A | Text messages |
| `image` | ✅ | ✅ | Images with optional caption |
| `video` | ✅ | ✅ | Videos with optional caption |
| `document` | ✅ | ✅ | PDFs, docs, etc. with filename |
| `audio` | ✅ | ✅ | Voice messages and audio files |
| `sticker` | ✅ | ✅ | Animated and static stickers |
| `location` | ✅ | N/A | GPS coordinates with name/address |
| `contacts` | ✅ | N/A | Shared contact cards (vCard) |
| `interactive` | ✅ | N/A | Button/list/flow replies |
| `button` | ✅ | N/A | Quick reply button responses |
| `reaction` | ✅ | N/A | Emoji reactions to messages |
| `order` | ✅ | N/A | Catalog/commerce orders |
| `system` | ✅ | N/A | System notifications |
### Status Updates
| Status | Event | Description |
|--------|-------|-------------|
| `sent` | `message.sent` | Message sent from your number |
| `delivered` | `message.delivered` | Message delivered to recipient |
| `read` | `message.read` | Message read by recipient |
| `failed` | `message.failed` | Message delivery failed |
### Webhook Notification Types
| Field | Description | Events Published |
|-------|-------------|------------------|
| `messages` | Message events | `message.received`, message status updates |
| `message_template_status_update` | Template changes | Logged to console |
| `account_update` | Account config changes | Logged to console |
| `phone_number_quality_update` | Quality rating changes | Logged to console |
| `phone_number_name_update` | Display name changes | Logged to console |
| `account_alerts` | Important alerts | Logged to console |
## Webhook Security
WhatsHooked implements proper webhook security:
1. **Verification**: Uses the `verify_token` to verify Meta's webhook setup request
2. **Account isolation**: Each account has its own webhook endpoint path
3. **No authentication required**: Meta's webhooks don't support custom auth headers
4. **Validation**: Verifies webhook payload structure
### Webhook Verification Flow
```
Meta sends: GET /webhooks/whatsapp/business?hub.mode=subscribe&hub.verify_token=YOUR_TOKEN&hub.challenge=CHALLENGE
WhatsHooked verifies token
Returns CHALLENGE (200 OK) if valid
403 Forbidden if invalid
```
### Receiving Messages
```
Meta sends: POST /webhooks/whatsapp/business
WhatsHooked processes webhook
Downloads media (if present)
Publishes to event bus
Triggers your configured hooks
Returns 200 OK
```
## Testing Webhooks
### Test with Meta's Test Button
1. In WhatsApp Configuration → Webhooks
2. Click **Test** next to "messages"
3. Select a sample event (e.g., "Text Message")
4. Click **Send to My Server**
5. Check WhatsHooked logs for the received event
### Test with Real Messages
1. Send a message to your WhatsApp Business number
2. Check WhatsHooked logs (set `"log_level": "debug"` for details):
```
DEBUG Publishing message received event account_id=business message_id=wamid.xxx from=1234567890 type=text
DEBUG Hook manager received event event_type=message.received
DEBUG Hook matches event hook_id=message_hook event_type=message.received
DEBUG Found relevant hooks for event event_type=message.received hook_count=1
DEBUG Sending to hook hook_id=message_hook url=https://your-webhook.com/messages
```
3. Your webhook should receive the payload
### Webhook Payload Example
```json
{
"account_id": "business",
"message_id": "wamid.HBgNMTIzNDU2Nzg5MAUCABEYEjQyMzRGRDhENzk5MkY5OUFBMQA",
"from": "1234567890",
"to": "1234567890",
"text": "Hello World",
"timestamp": "2026-01-30T12:00:00Z",
"is_group": false,
"sender_name": "John Doe",
"message_type": "text"
}
```
## Step 11: Configure Your Webhooks
## Step 11: Configure Your Webhooks
WhatsHooked forwards events to your own webhook URLs. Configure them in `config.json`:
```json
{
"hooks": [
{
"id": "message_hook",
"name": "Message Handler",
"url": "https://your-app.com/api/whatsapp/messages",
"method": "POST",
"headers": {
"Authorization": "Bearer your-app-token"
},
"active": true,
"events": [
"message.received",
"message.sent",
"message.delivered",
"message.read"
],
"description": "Receives all message events"
}
]
}
```
### Hook Configuration Fields
| Field | Required | Description |
|-------|----------|-------------|
| `id` | Yes | Unique identifier for this hook |
| `name` | Yes | Human-readable name |
| `url` | Yes | Your webhook URL to receive events |
| `method` | Yes | HTTP method (usually "POST") |
| `headers` | No | Custom headers (for authentication, etc.) |
| `active` | Yes | Enable/disable this hook |
| `events` | No | Event types to receive (empty = all events) |
| `description` | No | Description for documentation |
### Available Event Types
**Message Events:**
- `message.received` - Incoming messages
- `message.sent` - Outgoing messages
- `message.delivered` - Delivery confirmations
- `message.read` - Read receipts
- `message.failed` - Delivery failures
**Connection Events:**
- `whatsapp.connected` - Account connected
- `whatsapp.disconnected` - Account disconnected
**QR Code Events** (whatsmeow only):
- `whatsapp.qr.code` - QR code for pairing
- `whatsapp.qr.timeout` - QR code expired
- `whatsapp.qr.error` - QR code error
**Hook Events:**
- `hook.triggered` - Hook was called
- `hook.success` - Hook responded successfully
- `hook.failed` - Hook call failed
### Query Parameters
WhatsHooked automatically adds query parameters to your webhook URL:
```
https://your-app.com/api/whatsapp/messages?event=message.received&account_id=business
```
- `event` - The event type
- `account_id` - The WhatsApp account that triggered the event
## Message Cache System
WhatsHooked includes a message cache that stores events when no active webhooks are configured. This ensures zero message loss.
### Enable Message Cache
Add to your `config.json`:
```json
{
"message_cache": {
"enabled": true,
"data_path": "./data/message_cache",
"max_age_days": 7,
"max_events": 10000
}
}
```
### When Events Are Cached
Events are automatically cached when:
- No webhooks are configured for the event type
- All webhooks are inactive (`"active": false`)
- No webhooks match the event in their `events` array
### Cache Management API
**List cached events:**
```bash
curl -u username:password http://localhost:8080/api/cache
```
**Get cache statistics:**
```bash
curl -u username:password http://localhost:8080/api/cache/stats
```
**Replay all cached events:**
```bash
curl -X POST -u username:password http://localhost:8080/api/cache/replay
```
**Replay specific event:**
```bash
curl -X POST -u username:password \
"http://localhost:8080/api/cache/event/replay?id=EVENT_ID"
```
**Delete cached event:**
```bash
curl -X DELETE -u username:password \
"http://localhost:8080/api/cache/event/delete?id=EVENT_ID"
```
**Clear all cache:**
```bash
curl -X DELETE -u username:password \
"http://localhost:8080/api/cache/clear?confirm=true"
```
### Cache Workflow Example
1. **Disable webhooks** → New messages get cached
2. **Configure/enable webhooks** → Future messages delivered immediately
3. **Call replay API** → Cached messages delivered to webhooks
4. **Successful delivery** → Events removed from cache automatically
## Troubleshooting
### Webhooks Not Receiving Events
**Check these items:**
1. **Verify token is correct** in both `config.json` and Meta Developer Console
2. **Check webhook is active** in Meta console (green checkmark)
3. **Verify URL is accessible** from internet (Meta needs to reach it)
4. **Check logs** with `"log_level": "debug"`:
```
DEBUG Publishing message received event account_id=business
DEBUG Hook manager received event event_type=message.received
DEBUG Hook matches event hook_id=message_hook
```
5. **Test with curl**:
```bash
# Send test message to your WhatsApp Business number
# Check if webhook receives it
```
### Webhook Verification Fails
**Error**: "The callback URL or verify token couldn't be validated"
**Causes**:
- `verify_token` mismatch between config.json and Meta console
- WhatsHooked server not running
- Firewall blocking Meta's IP ranges
- Wrong webhook URL format
**Fix**:
1. Ensure server is running: `./bin/whatshook-server -config config.json`
2. Check logs for verification attempt
3. Verify token matches exactly (case-sensitive)
4. Test URL is accessible: `curl https://your-domain.com/webhooks/whatsapp/business`
### Messages Not Cached
**Check**:
1. `message_cache.enabled` is `true` in config
2. Hooks are actually inactive or not matching events
3. Check cache stats: `curl -u user:pass http://localhost:8080/api/cache/stats`
### No Hooks Configured Error
If events are being cached but you have hooks configured, check:
- Hook `"active"` is `true`
- Hook `"events"` array includes the event type (or is empty for all events)
- Hook URL is reachable and responding with 2xx status
Enable debug logging to trace the issue:
```json
{
"log_level": "debug"
}
```
## Webhook Payload Examples
### Text Message
```json
{
"account_id": "business",
"message_id": "wamid.HBgNMTIzNDU2Nzg5MAUCABEYEjQyMzRGRDhENzk5MkY5OUFBMQA",
"from": "1234567890",
"to": "1234567890",
"text": "Hello, how can I help?",
"timestamp": "2026-01-30T12:00:00Z",
"is_group": false,
"sender_name": "John Doe",
"message_type": "text"
}
```
### Image Message (with media)
```json
{
"account_id": "business",
"message_id": "wamid.xxx",
"from": "1234567890",
"to": "1234567890",
"text": "Check this out!",
"timestamp": "2026-01-30T12:00:00Z",
"is_group": false,
"sender_name": "John Doe",
"message_type": "image",
"media": {
"type": "image",
"mime_type": "image/jpeg",
"filename": "wamid.xxx_a1b2c3d4.jpg",
"url": "http://localhost:8080/api/media/business/wamid.xxx_a1b2c3d4.jpg",
"base64": "..." // Only if media.mode is "base64" or "both"
}
}
```
### Location Message
```json
{
"account_id": "business",
"message_id": "wamid.xxx",
"from": "1234567890",
"to": "1234567890",
"text": "Location: Office (123 Main St) - 40.712800, -74.006000",
"timestamp": "2026-01-30T12:00:00Z",
"is_group": false,
"sender_name": "John Doe",
"message_type": "location"
}
```
### Button Reply (Interactive)
```json
{
"account_id": "business",
"message_id": "wamid.xxx",
"from": "1234567890",
"to": "1234567890",
"text": "Yes, I'm interested",
"timestamp": "2026-01-30T12:00:00Z",
"is_group": false,
"sender_name": "John Doe",
"message_type": "interactive"
}
```
### Delivery Status
```json
{
"event_type": "message.delivered",
"timestamp": "2026-01-30T12:00:05Z",
"data": {
"account_id": "business",
"message_id": "wamid.xxx",
"from": "1234567890",
"timestamp": "2026-01-30T12:00:05Z"
}
}
```
## Complete Configuration Example
Here's a complete `config.json` with all Business API features:
```json
{
"server": {
"host": "0.0.0.0",
"port": 8080,
"default_country_code": "1",
"username": "admin",
"password": "secure_password",
"auth_key": "optional_api_key"
},
"whatsapp": [
{
"id": "business",
"type": "business-api",
"phone_number": "+1234567890",
"business_api": {
"phone_number_id": "123456789012345",
"access_token": "EAAxxxxxxxxxxxx",
"business_account_id": "987654321098765",
"api_version": "v21.0",
"verify_token": "my_secure_random_token_abc123"
}
}
],
"hooks": [
{
"id": "message_hook",
"name": "Message Handler",
"url": "https://your-app.com/api/whatsapp/messages",
"method": "POST",
"headers": {
"Authorization": "Bearer your-app-secret-token",
"X-Custom-Header": "value"
},
"active": true,
"events": [
"message.received",
"message.sent",
"message.delivered",
"message.read"
],
"description": "Handles all message events"
},
{
"id": "status_hook",
"name": "Connection Monitor",
"url": "https://your-app.com/api/whatsapp/status",
"method": "POST",
"active": true,
"events": [
"whatsapp.connected",
"whatsapp.disconnected"
],
"description": "Monitors connection status"
}
],
"media": {
"data_path": "./data/media",
"mode": "link",
"base_url": "https://your-domain.com"
},
"message_cache": {
"enabled": true,
"data_path": "./data/message_cache",
"max_age_days": 7,
"max_events": 10000
},
"event_logger": {
"enabled": true,
"targets": ["file", "sqlite"],
"file_dir": "./data/events",
"table_name": "event_logs"
},
"log_level": "info"
}
```
## Advanced Features
### Media Handling Modes
WhatsHooked supports three media delivery modes:
**1. Link Mode** (default, recommended)
```json
{
"media": {
"mode": "link",
"base_url": "https://your-domain.com"
}
}
```
- Downloads media and stores locally
- Webhooks receive URL: `https://your-domain.com/api/media/business/filename.jpg`
- Efficient for large media files
**2. Base64 Mode**
```json
{
"media": {
"mode": "base64"
}
}
```
- Encodes media as base64 in webhook payload
- No separate download needed
- Good for small files, increases payload size
**3. Both Mode**
```json
{
"media": {
"mode": "both"
}
}
```
- Provides both URL and base64
- Maximum flexibility, largest payloads
### Event Logger
Track all events to file and/or database:
```json
{
"event_logger": {
"enabled": true,
"targets": ["file", "sqlite", "postgres"],
"file_dir": "./data/events",
"table_name": "event_logs"
},
"database": {
"type": "postgres",
"host": "localhost",
"port": 5432,
"username": "whatshooked",
"password": "password",
"database": "whatshooked"
}
}
```
Logged events include:
- All message events
- Connection status changes
- Hook success/failure
- Webhook triggers
### Two-Way Communication
Your webhooks can respond to trigger outgoing messages:
**Webhook Response Format:**
```json
{
"send_message": true,
"to": "1234567890",
"text": "Thanks for your message!",
"account_id": "business"
}
```
This sends a reply immediately when your webhook receives an event.
## Production Deployment Checklist
Before going live:
- [ ] Use a System User token (not personal user token)
- [ ] Set `verify_token` to a secure random string (32+ characters)
- [ ] Configure webhooks in Meta Developer Console
- [ ] Subscribe to required webhook fields (messages, etc.)
- [ ] Test webhook verification succeeds
- [ ] Enable HTTPS for production (required by Meta)
- [ ] Set up firewall rules to allow Meta's webhook IPs
- [ ] Configure authentication (`username`/`password` or `auth_key`)
- [ ] Enable message cache for reliability
- [ ] Set up event logging for audit trail
- [ ] Test sending and receiving messages
- [ ] Monitor logs for errors
- [ ] Set up log rotation for production
- [ ] Document your webhook endpoints
- [ ] Set up monitoring/alerts for webhook failures
## Troubleshooting Common Issues
### 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=

394
pkg/cache/message_cache.go vendored Normal file
View File

@@ -0,0 +1,394 @@
package cache
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"time"
"git.warky.dev/wdevs/whatshooked/pkg/events"
"git.warky.dev/wdevs/whatshooked/pkg/logging"
)
// CachedEvent represents an event stored in cache
type CachedEvent struct {
ID string `json:"id"`
Event events.Event `json:"event"`
Timestamp time.Time `json:"timestamp"`
Reason string `json:"reason"`
Attempts int `json:"attempts"`
LastAttempt *time.Time `json:"last_attempt,omitempty"`
}
// MessageCache manages cached events when no webhooks are available
type MessageCache struct {
events map[string]*CachedEvent
mu sync.RWMutex
dataPath string
enabled bool
maxAge time.Duration // Maximum age before events are purged
maxEvents int // Maximum number of events to keep
}
// Config holds cache configuration
type Config struct {
Enabled bool `json:"enabled"`
DataPath string `json:"data_path"`
MaxAge time.Duration `json:"max_age"` // Default: 7 days
MaxEvents int `json:"max_events"` // Default: 10000
}
// NewMessageCache creates a new message cache
func NewMessageCache(cfg Config) (*MessageCache, error) {
if !cfg.Enabled {
return &MessageCache{
enabled: false,
}, nil
}
if cfg.DataPath == "" {
cfg.DataPath = "./data/cache"
}
if cfg.MaxAge == 0 {
cfg.MaxAge = 7 * 24 * time.Hour // 7 days
}
if cfg.MaxEvents == 0 {
cfg.MaxEvents = 10000
}
// Create cache directory
if err := os.MkdirAll(cfg.DataPath, 0755); err != nil {
return nil, fmt.Errorf("failed to create cache directory: %w", err)
}
cache := &MessageCache{
events: make(map[string]*CachedEvent),
dataPath: cfg.DataPath,
enabled: true,
maxAge: cfg.MaxAge,
maxEvents: cfg.MaxEvents,
}
// Load existing cached events
if err := cache.loadFromDisk(); err != nil {
logging.Warn("Failed to load cached events from disk", "error", err)
}
// Start cleanup goroutine
go cache.cleanupLoop()
logging.Info("Message cache initialized",
"enabled", cfg.Enabled,
"data_path", cfg.DataPath,
"max_age", cfg.MaxAge,
"max_events", cfg.MaxEvents)
return cache, nil
}
// Store adds an event to the cache
func (c *MessageCache) Store(event events.Event, reason string) error {
if !c.enabled {
return nil
}
c.mu.Lock()
defer c.mu.Unlock()
// Check if we're at capacity
if len(c.events) >= c.maxEvents {
// Remove oldest event
c.removeOldest()
}
// Generate unique ID
id := fmt.Sprintf("%d-%s", time.Now().UnixNano(), event.Type)
cached := &CachedEvent{
ID: id,
Event: event,
Timestamp: time.Now(),
Reason: reason,
Attempts: 0,
}
c.events[id] = cached
// Save to disk asynchronously
go c.saveToDisk(cached)
logging.Debug("Event cached",
"event_id", id,
"event_type", event.Type,
"reason", reason,
"cache_size", len(c.events))
return nil
}
// Get retrieves a cached event by ID
func (c *MessageCache) Get(id string) (*CachedEvent, bool) {
if !c.enabled {
return nil, false
}
c.mu.RLock()
defer c.mu.RUnlock()
event, exists := c.events[id]
return event, exists
}
// List returns all cached events
func (c *MessageCache) List() []*CachedEvent {
if !c.enabled {
return nil
}
c.mu.RLock()
defer c.mu.RUnlock()
result := make([]*CachedEvent, 0, len(c.events))
for _, event := range c.events {
result = append(result, event)
}
return result
}
// ListByEventType returns cached events filtered by event type
func (c *MessageCache) ListByEventType(eventType events.EventType) []*CachedEvent {
if !c.enabled {
return nil
}
c.mu.RLock()
defer c.mu.RUnlock()
result := make([]*CachedEvent, 0)
for _, cached := range c.events {
if cached.Event.Type == eventType {
result = append(result, cached)
}
}
return result
}
// Remove deletes an event from the cache
func (c *MessageCache) Remove(id string) error {
if !c.enabled {
return nil
}
c.mu.Lock()
defer c.mu.Unlock()
if _, exists := c.events[id]; !exists {
return fmt.Errorf("cached event not found: %s", id)
}
delete(c.events, id)
// Remove from disk
go c.removeFromDisk(id)
logging.Debug("Event removed from cache", "event_id", id)
return nil
}
// IncrementAttempts increments the delivery attempt counter
func (c *MessageCache) IncrementAttempts(id string) error {
if !c.enabled {
return nil
}
c.mu.Lock()
defer c.mu.Unlock()
cached, exists := c.events[id]
if !exists {
return fmt.Errorf("cached event not found: %s", id)
}
now := time.Now()
cached.Attempts++
cached.LastAttempt = &now
// Update on disk
go c.saveToDisk(cached)
return nil
}
// Clear removes all cached events
func (c *MessageCache) Clear() error {
if !c.enabled {
return nil
}
c.mu.Lock()
defer c.mu.Unlock()
c.events = make(map[string]*CachedEvent)
// Clear disk cache
go c.clearDisk()
logging.Info("Message cache cleared")
return nil
}
// Count returns the number of cached events
func (c *MessageCache) Count() int {
if !c.enabled {
return 0
}
c.mu.RLock()
defer c.mu.RUnlock()
return len(c.events)
}
// IsEnabled returns whether the cache is enabled
func (c *MessageCache) IsEnabled() bool {
return c.enabled
}
// removeOldest removes the oldest event from the cache
func (c *MessageCache) removeOldest() {
var oldestID string
var oldestTime time.Time
for id, cached := range c.events {
if oldestID == "" || cached.Timestamp.Before(oldestTime) {
oldestID = id
oldestTime = cached.Timestamp
}
}
if oldestID != "" {
delete(c.events, oldestID)
go c.removeFromDisk(oldestID)
logging.Debug("Removed oldest cached event due to capacity", "event_id", oldestID)
}
}
// cleanupLoop periodically removes expired events
func (c *MessageCache) cleanupLoop() {
ticker := time.NewTicker(1 * time.Hour)
defer ticker.Stop()
for range ticker.C {
c.cleanup()
}
}
// cleanup removes expired events
func (c *MessageCache) cleanup() {
if !c.enabled {
return
}
c.mu.Lock()
defer c.mu.Unlock()
now := time.Now()
expiredIDs := make([]string, 0)
for id, cached := range c.events {
if now.Sub(cached.Timestamp) > c.maxAge {
expiredIDs = append(expiredIDs, id)
}
}
for _, id := range expiredIDs {
delete(c.events, id)
go c.removeFromDisk(id)
}
if len(expiredIDs) > 0 {
logging.Info("Cleaned up expired cached events", "count", len(expiredIDs))
}
}
// saveToDisk saves a cached event to disk
func (c *MessageCache) saveToDisk(cached *CachedEvent) {
filePath := filepath.Join(c.dataPath, fmt.Sprintf("%s.json", cached.ID))
data, err := json.MarshalIndent(cached, "", " ")
if err != nil {
logging.Error("Failed to marshal cached event", "event_id", cached.ID, "error", err)
return
}
if err := os.WriteFile(filePath, data, 0644); err != nil {
logging.Error("Failed to save cached event to disk", "event_id", cached.ID, "error", err)
}
}
// loadFromDisk loads all cached events from disk
func (c *MessageCache) loadFromDisk() error {
files, err := filepath.Glob(filepath.Join(c.dataPath, "*.json"))
if err != nil {
return fmt.Errorf("failed to list cache files: %w", err)
}
loaded := 0
for _, file := range files {
data, err := os.ReadFile(file)
if err != nil {
logging.Warn("Failed to read cache file", "file", file, "error", err)
continue
}
var cached CachedEvent
if err := json.Unmarshal(data, &cached); err != nil {
logging.Warn("Failed to unmarshal cache file", "file", file, "error", err)
continue
}
// Skip expired events
if time.Since(cached.Timestamp) > c.maxAge {
os.Remove(file)
continue
}
c.events[cached.ID] = &cached
loaded++
}
if loaded > 0 {
logging.Info("Loaded cached events from disk", "count", loaded)
}
return nil
}
// removeFromDisk removes a cached event file from disk
func (c *MessageCache) removeFromDisk(id string) {
filePath := filepath.Join(c.dataPath, fmt.Sprintf("%s.json", id))
if err := os.Remove(filePath); err != nil && !os.IsNotExist(err) {
logging.Error("Failed to remove cached event from disk", "event_id", id, "error", err)
}
}
// clearDisk removes all cache files from disk
func (c *MessageCache) clearDisk() {
files, err := filepath.Glob(filepath.Join(c.dataPath, "*.json"))
if err != nil {
logging.Error("Failed to list cache files for clearing", "error", err)
return
}
for _, file := range files {
if err := os.Remove(file); err != nil {
logging.Error("Failed to remove cache file", "file", file, "error", err)
}
}
}

View File

@@ -13,6 +13,7 @@ type Config struct {
Database DatabaseConfig `json:"database,omitempty"` Database DatabaseConfig `json:"database,omitempty"`
Media MediaConfig `json:"media"` Media MediaConfig `json:"media"`
EventLogger EventLoggerConfig `json:"event_logger,omitempty"` EventLogger EventLoggerConfig `json:"event_logger,omitempty"`
MessageCache MessageCacheConfig `json:"message_cache,omitempty"`
LogLevel string `json:"log_level"` LogLevel string `json:"log_level"`
} }
@@ -122,6 +123,14 @@ type MQTTConfig struct {
Subscribe bool `json:"subscribe,omitempty"` // Enable subscription for sending messages Subscribe bool `json:"subscribe,omitempty"` // Enable subscription for sending messages
} }
// MessageCacheConfig holds message cache configuration
type MessageCacheConfig struct {
Enabled bool `json:"enabled"` // Enable message caching
DataPath string `json:"data_path,omitempty"` // Directory to store cached events
MaxAgeDays int `json:"max_age_days,omitempty"` // Maximum age in days before purging (default: 7)
MaxEvents int `json:"max_events,omitempty"` // Maximum number of events to cache (default: 10000)
}
// Load reads configuration from a file // Load reads configuration from a file
func Load(path string) (*Config, error) { func Load(path string) (*Config, error) {
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
@@ -186,6 +195,17 @@ func Load(path string) (*Config, error) {
} }
} }
// Set message cache defaults
if cfg.MessageCache.DataPath == "" {
cfg.MessageCache.DataPath = "./data/message_cache"
}
if cfg.MessageCache.MaxAgeDays == 0 {
cfg.MessageCache.MaxAgeDays = 7
}
if cfg.MessageCache.MaxEvents == 0 {
cfg.MessageCache.MaxEvents = 10000
}
return &cfg, nil return &cfg, nil
} }

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

@@ -21,10 +21,11 @@ type MQTTTarget struct {
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")
} }
@@ -44,6 +45,7 @@ func NewMQTTTarget(cfg config.MQTTConfig, waManager WhatsAppManager) (*MQTTTarge
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

@@ -10,16 +10,21 @@ import (
// BusinessAPIWebhook handles both verification (GET) and webhook events (POST) // BusinessAPIWebhook handles both verification (GET) and webhook events (POST)
func (h *Handlers) BusinessAPIWebhook(w http.ResponseWriter, r *http.Request) { func (h *Handlers) BusinessAPIWebhook(w http.ResponseWriter, r *http.Request) {
accountID := extractAccountIDFromPath(r.URL.Path)
if r.Method == http.MethodGet { if r.Method == http.MethodGet {
logging.Info("WhatsApp webhook verification request", "account_id", accountID, "method", "GET")
h.businessAPIWebhookVerify(w, r) h.businessAPIWebhookVerify(w, r)
return return
} }
if r.Method == http.MethodPost { if r.Method == http.MethodPost {
logging.Info("WhatsApp webhook event received", "account_id", accountID, "method", "POST")
h.businessAPIWebhookEvent(w, r) h.businessAPIWebhookEvent(w, r)
return return
} }
logging.Warn("WhatsApp webhook invalid method", "account_id", accountID, "method", r.Method)
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
} }
@@ -29,6 +34,7 @@ func (h *Handlers) businessAPIWebhookVerify(w http.ResponseWriter, r *http.Reque
// Extract account ID from URL path // Extract account ID from URL path
accountID := extractAccountIDFromPath(r.URL.Path) accountID := extractAccountIDFromPath(r.URL.Path)
if accountID == "" { if accountID == "" {
logging.Warn("WhatsApp webhook verification missing account ID")
http.Error(w, "Account ID required in path", http.StatusBadRequest) http.Error(w, "Account ID required in path", http.StatusBadRequest)
return return
} }
@@ -94,10 +100,13 @@ func (h *Handlers) businessAPIWebhookEvent(w http.ResponseWriter, r *http.Reques
// Extract account ID from URL path // Extract account ID from URL path
accountID := extractAccountIDFromPath(r.URL.Path) accountID := extractAccountIDFromPath(r.URL.Path)
if accountID == "" { if accountID == "" {
logging.Warn("WhatsApp webhook event missing account ID")
http.Error(w, "Account ID required in path", http.StatusBadRequest) http.Error(w, "Account ID required in path", http.StatusBadRequest)
return return
} }
logging.Info("WhatsApp webhook processing started", "account_id", accountID)
// Get the client from the manager // Get the client from the manager
client, exists := h.whatsappMgr.GetClient(accountID) client, exists := h.whatsappMgr.GetClient(accountID)
if !exists { if !exists {
@@ -128,6 +137,8 @@ func (h *Handlers) businessAPIWebhookEvent(w http.ResponseWriter, r *http.Reques
return return
} }
logging.Info("WhatsApp webhook processed successfully", "account_id", accountID)
// Return 200 OK to acknowledge receipt // Return 200 OK to acknowledge receipt
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
writeBytes(w, []byte("OK")) writeBytes(w, []byte("OK"))

254
pkg/handlers/cache.go Normal file
View File

@@ -0,0 +1,254 @@
package handlers
import (
"encoding/json"
"net/http"
"strconv"
"git.warky.dev/wdevs/whatshooked/pkg/events"
"git.warky.dev/wdevs/whatshooked/pkg/logging"
)
// GetCachedEvents returns all cached events
// GET /api/cache
func (h *Handlers) GetCachedEvents(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
cache := h.hookMgr.GetCache()
if cache == nil || !cache.IsEnabled() {
http.Error(w, "Message cache is not enabled", http.StatusServiceUnavailable)
return
}
// Optional event_type filter
eventType := r.URL.Query().Get("event_type")
var cachedEvents interface{}
if eventType != "" {
cachedEvents = cache.ListByEventType(events.EventType(eventType))
} else {
cachedEvents = cache.List()
}
writeJSON(w, map[string]interface{}{
"cached_events": cachedEvents,
"count": cache.Count(),
})
}
// GetCachedEvent returns a specific cached event by ID
// GET /api/cache/{id}
func (h *Handlers) GetCachedEvent(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
cache := h.hookMgr.GetCache()
if cache == nil || !cache.IsEnabled() {
http.Error(w, "Message cache is not enabled", http.StatusServiceUnavailable)
return
}
// Extract ID from path
id := r.URL.Query().Get("id")
if id == "" {
http.Error(w, "Event ID required", http.StatusBadRequest)
return
}
cached, exists := cache.Get(id)
if !exists {
http.Error(w, "Cached event not found", http.StatusNotFound)
return
}
writeJSON(w, cached)
}
// ReplayCachedEvents replays all cached events
// POST /api/cache/replay
func (h *Handlers) ReplayCachedEvents(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
cache := h.hookMgr.GetCache()
if cache == nil || !cache.IsEnabled() {
http.Error(w, "Message cache is not enabled", http.StatusServiceUnavailable)
return
}
logging.Info("Replaying all cached events via API")
successCount, failCount, err := h.hookMgr.ReplayCachedEvents()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]interface{}{
"success": true,
"replayed": successCount + failCount,
"delivered": successCount,
"failed": failCount,
"remaining_cached": cache.Count(),
})
}
// ReplayCachedEvent replays a specific cached event
// POST /api/cache/replay/{id}
func (h *Handlers) ReplayCachedEvent(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
cache := h.hookMgr.GetCache()
if cache == nil || !cache.IsEnabled() {
http.Error(w, "Message cache is not enabled", http.StatusServiceUnavailable)
return
}
// Extract ID from request body or query param
var req struct {
ID string `json:"id"`
}
// Try query param first
id := r.URL.Query().Get("id")
if id == "" {
// Try JSON body
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
id = req.ID
}
if id == "" {
http.Error(w, "Event ID required", http.StatusBadRequest)
return
}
logging.Info("Replaying cached event via API", "event_id", id)
if err := h.hookMgr.ReplayCachedEvent(id); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]interface{}{
"success": true,
"event_id": id,
"message": "Event replayed successfully",
})
}
// DeleteCachedEvent removes a specific cached event
// DELETE /api/cache/{id}
func (h *Handlers) DeleteCachedEvent(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
cache := h.hookMgr.GetCache()
if cache == nil || !cache.IsEnabled() {
http.Error(w, "Message cache is not enabled", http.StatusServiceUnavailable)
return
}
id := r.URL.Query().Get("id")
if id == "" {
http.Error(w, "Event ID required", http.StatusBadRequest)
return
}
logging.Info("Deleting cached event via API", "event_id", id)
if err := cache.Remove(id); err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
writeJSON(w, map[string]interface{}{
"success": true,
"event_id": id,
"message": "Cached event deleted successfully",
})
}
// ClearCache removes all cached events
// DELETE /api/cache
func (h *Handlers) ClearCache(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
cache := h.hookMgr.GetCache()
if cache == nil || !cache.IsEnabled() {
http.Error(w, "Message cache is not enabled", http.StatusServiceUnavailable)
return
}
// Optional confirmation parameter
confirm := r.URL.Query().Get("confirm")
confirmInt, _ := strconv.ParseBool(confirm)
if !confirmInt {
http.Error(w, "Add ?confirm=true to confirm cache clearing", http.StatusBadRequest)
return
}
count := cache.Count()
logging.Warn("Clearing all cached events via API", "count", count)
if err := cache.Clear(); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]interface{}{
"success": true,
"cleared": count,
"message": "Cache cleared successfully",
})
}
// GetCacheStats returns cache statistics
// GET /api/cache/stats
func (h *Handlers) GetCacheStats(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
cache := h.hookMgr.GetCache()
if cache == nil || !cache.IsEnabled() {
writeJSON(w, map[string]interface{}{
"enabled": false,
"count": 0,
})
return
}
// Group by event type
cachedEvents := cache.List()
eventTypeCounts := make(map[string]int)
for _, cached := range cachedEvents {
eventTypeCounts[string(cached.Event.Type)]++
}
writeJSON(w, map[string]interface{}{
"enabled": true,
"total_count": cache.Count(),
"by_event_type": eventTypeCounts,
})
}

View File

@@ -11,6 +11,7 @@ import (
"sync" "sync"
"time" "time"
"git.warky.dev/wdevs/whatshooked/pkg/cache"
"git.warky.dev/wdevs/whatshooked/pkg/config" "git.warky.dev/wdevs/whatshooked/pkg/config"
"git.warky.dev/wdevs/whatshooked/pkg/events" "git.warky.dev/wdevs/whatshooked/pkg/events"
"git.warky.dev/wdevs/whatshooked/pkg/logging" "git.warky.dev/wdevs/whatshooked/pkg/logging"
@@ -54,13 +55,15 @@ type Manager struct {
mu sync.RWMutex mu sync.RWMutex
client *http.Client client *http.Client
eventBus *events.EventBus eventBus *events.EventBus
cache *cache.MessageCache
} }
// NewManager creates a new hook manager // NewManager creates a new hook manager
func NewManager(eventBus *events.EventBus) *Manager { func NewManager(eventBus *events.EventBus, messageCache *cache.MessageCache) *Manager {
return &Manager{ return &Manager{
hooks: make(map[string]config.Hook), hooks: make(map[string]config.Hook),
eventBus: eventBus, eventBus: eventBus,
cache: messageCache,
client: &http.Client{ client: &http.Client{
Timeout: 30 * time.Second, Timeout: 30 * time.Second,
}, },
@@ -90,20 +93,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 +121,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,14 +129,50 @@ 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))
// If no relevant hooks found, cache the event
if len(relevantHooks) == 0 {
if m.cache != nil && m.cache.IsEnabled() {
reason := fmt.Sprintf("No active webhooks configured for event type: %s", event.Type)
if err := m.cache.Store(event, reason); err != nil {
logging.Error("Failed to cache event", "event_type", event.Type, "error", err)
} else {
logging.Info("Event cached due to no active webhooks",
"event_type", event.Type,
"cache_size", m.cache.Count())
}
} else {
logging.Warn("No active webhooks for event and caching is disabled",
"event_type", event.Type)
}
return
}
// Trigger each relevant hook // Trigger each relevant hook
if len(relevantHooks) > 0 { success := m.triggerHooksForEvent(event, relevantHooks)
m.triggerHooksForEvent(event, relevantHooks)
// If event was successfully delivered and it was previously cached, remove it from cache
if success && m.cache != nil && m.cache.IsEnabled() {
// Try to find and remove this event from cache
// (This handles the case where a cached event is being replayed)
cachedEvents := m.cache.List()
for _, cached := range cachedEvents {
if cached.Event.Type == event.Type &&
cached.Event.Timestamp.Equal(event.Timestamp) {
if err := m.cache.Remove(cached.ID); err == nil {
logging.Info("Cached event successfully delivered and removed from cache",
"event_id", cached.ID,
"event_type", event.Type)
}
break
}
}
} }
} }
// triggerHooksForEvent sends event data to specific hooks // triggerHooksForEvent sends event data to specific hooks and returns success status
func (m *Manager) triggerHooksForEvent(event events.Event, hooks []config.Hook) { func (m *Manager) triggerHooksForEvent(event events.Event, hooks []config.Hook) bool {
ctx := event.Context ctx := event.Context
if ctx == nil { if ctx == nil {
ctx = context.Background() ctx = context.Background()
@@ -175,14 +221,26 @@ func (m *Manager) triggerHooksForEvent(event events.Event, hooks []config.Hook)
// Send to each hook with the event type // Send to each hook with the event type
var wg sync.WaitGroup var wg sync.WaitGroup
successCount := 0
mu := sync.Mutex{}
for _, hook := range hooks { for _, hook := range hooks {
wg.Add(1) wg.Add(1)
go func(h config.Hook, et events.EventType) { go func(h config.Hook, et events.EventType) {
defer wg.Done() defer wg.Done()
_ = m.sendToHook(ctx, h, payload, et) resp := m.sendToHook(ctx, h, payload, et)
if resp != nil || ctx.Err() == nil {
// Count as success if we got a response or context is still valid
mu.Lock()
successCount++
mu.Unlock()
}
}(hook, event.Type) }(hook, event.Type)
} }
wg.Wait() wg.Wait()
// Return true if at least one hook was successfully triggered
return successCount > 0
} }
// Helper functions to extract data from event map // Helper functions to extract data from event map
@@ -265,17 +323,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 +348,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 +376,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 +393,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 +408,97 @@ 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
} }
// ReplayCachedEvents attempts to replay all cached events
func (m *Manager) ReplayCachedEvents() (successCountResult int, failCountResult int, err error) {
if m.cache == nil || !m.cache.IsEnabled() {
return 0, 0, fmt.Errorf("message cache is not enabled")
}
cachedEvents := m.cache.List()
if len(cachedEvents) == 0 {
return 0, 0, nil
}
logging.Info("Replaying cached events", "count", len(cachedEvents))
successCount := 0
failCount := 0
for _, cached := range cachedEvents {
// Try to process the event again
m.handleEvent(cached.Event)
// Increment attempt counter
if err := m.cache.IncrementAttempts(cached.ID); err != nil {
logging.Error("Failed to increment attempt counter", "event_id", cached.ID, "error", err)
}
// Check if event was successfully delivered by seeing if it's still cached
// (handleEvent will remove it from cache if successfully delivered)
time.Sleep(100 * time.Millisecond) // Give time for async delivery
if _, exists := m.cache.Get(cached.ID); !exists {
successCount++
logging.Debug("Cached event successfully replayed", "event_id", cached.ID)
} else {
failCount++
}
}
logging.Info("Cached event replay complete",
"success", successCount,
"failed", failCount,
"remaining_cached", m.cache.Count())
return successCount, failCount, nil
}
// ReplayCachedEvent attempts to replay a single cached event by ID
func (m *Manager) ReplayCachedEvent(id string) error {
if m.cache == nil || !m.cache.IsEnabled() {
return fmt.Errorf("message cache is not enabled")
}
cached, exists := m.cache.Get(id)
if !exists {
return fmt.Errorf("cached event not found: %s", id)
}
logging.Info("Replaying cached event", "event_id", id, "event_type", cached.Event.Type)
// Process the event
m.handleEvent(cached.Event)
// Increment attempt counter
if err := m.cache.IncrementAttempts(id); err != nil {
logging.Error("Failed to increment attempt counter", "event_id", id, "error", err)
}
return nil
}
// GetCache returns the message cache (for external access)
func (m *Manager) GetCache() *cache.MessageCache {
return m.cache
}

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"
@@ -30,13 +31,24 @@ func (c *Client) HandleWebhook(r *http.Request) error {
return fmt.Errorf("failed to parse webhook payload: %w", err) return fmt.Errorf("failed to parse webhook payload: %w", err)
} }
logging.Info("Processing webhook payload",
"account_id", c.id,
"entries", len(payload.Entry))
// Process each entry // Process each entry
changeCount := 0
for _, entry := range payload.Entry { for _, entry := range payload.Entry {
changeCount += len(entry.Changes)
for i := range entry.Changes { for i := range entry.Changes {
c.processChange(entry.Changes[i]) c.processChange(entry.Changes[i])
} }
} }
logging.Info("Webhook payload processed",
"account_id", c.id,
"entries", len(payload.Entry),
"changes", changeCount)
return nil return nil
} }
@@ -44,8 +56,17 @@ 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()
logging.Info("Processing webhook change",
"account_id", c.id,
"field", change.Field,
"phone_number_id", change.Value.Metadata.PhoneNumberID)
// Handle different field types
switch change.Field {
case "messages":
// Process messages // Process messages
for _, msg := range change.Value.Messages { for i := range change.Value.Messages {
msg := change.Value.Messages[i]
c.processMessage(ctx, msg, change.Value.Contacts) c.processMessage(ctx, msg, change.Value.Contacts)
} }
@@ -53,6 +74,42 @@ func (c *Client) processChange(change WebhookChange) {
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)
}
} }
// processMessage processes an incoming message // processMessage processes an incoming message
@@ -130,12 +187,115 @@ 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 i := range msg.Contacts {
contact := msg.Contacts[i]
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.Info("Message received via WhatsApp",
"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,
@@ -164,15 +324,15 @@ func (c *Client) processStatus(ctx context.Context, status WebhookStatus) {
switch status.Status { switch status.Status {
case "sent": case "sent":
c.eventBus.Publish(events.MessageSentEvent(ctx, c.id, status.ID, status.RecipientID, "")) c.eventBus.Publish(events.MessageSentEvent(ctx, c.id, status.ID, status.RecipientID, ""))
logging.Debug("Message sent status", "account_id", c.id, "message_id", status.ID) logging.Info("Message status: sent", "account_id", c.id, "message_id", status.ID, "recipient", status.RecipientID)
case "delivered": case "delivered":
c.eventBus.Publish(events.MessageDeliveredEvent(ctx, c.id, status.ID, status.RecipientID, timestamp)) c.eventBus.Publish(events.MessageDeliveredEvent(ctx, c.id, status.ID, status.RecipientID, timestamp))
logging.Debug("Message delivered", "account_id", c.id, "message_id", status.ID) logging.Info("Message status: delivered", "account_id", c.id, "message_id", status.ID, "recipient", status.RecipientID)
case "read": case "read":
c.eventBus.Publish(events.MessageReadEvent(ctx, c.id, status.ID, status.RecipientID, timestamp)) c.eventBus.Publish(events.MessageReadEvent(ctx, c.id, status.ID, status.RecipientID, timestamp))
logging.Debug("Message read", "account_id", c.id, "message_id", status.ID) logging.Info("Message status: read", "account_id", c.id, "message_id", status.ID, "recipient", status.RecipientID)
case "failed": case "failed":
errMsg := "unknown error" errMsg := "unknown error"
@@ -276,7 +436,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

@@ -119,12 +119,23 @@ 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"`
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 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

@@ -232,11 +232,21 @@ func (s *Server) setupRoutes() *http.ServeMux {
// Business API webhooks (no auth - Meta validates via verify_token) // Business API webhooks (no auth - Meta validates via verify_token)
mux.HandleFunc("/webhooks/whatsapp/", h.BusinessAPIWebhook) mux.HandleFunc("/webhooks/whatsapp/", h.BusinessAPIWebhook)
// Message cache management (with auth)
mux.HandleFunc("/api/cache", h.Auth(h.GetCachedEvents)) // GET - list cached events
mux.HandleFunc("/api/cache/stats", h.Auth(h.GetCacheStats)) // GET - cache statistics
mux.HandleFunc("/api/cache/replay", h.Auth(h.ReplayCachedEvents)) // POST - replay all
mux.HandleFunc("/api/cache/event", h.Auth(h.GetCachedEvent)) // GET with ?id=
mux.HandleFunc("/api/cache/event/replay", h.Auth(h.ReplayCachedEvent)) // POST with ?id=
mux.HandleFunc("/api/cache/event/delete", h.Auth(h.DeleteCachedEvent)) // DELETE with ?id=
mux.HandleFunc("/api/cache/clear", h.Auth(h.ClearCache)) // DELETE with ?confirm=true
logging.Info("HTTP server endpoints configured", logging.Info("HTTP server endpoints configured",
"health", "/health", "health", "/health",
"hooks", "/api/hooks", "hooks", "/api/hooks",
"accounts", "/api/accounts", "accounts", "/api/accounts",
"send", "/api/send", "send", "/api/send",
"cache", "/api/cache",
"qr", "/api/qr") "qr", "/api/qr")
return mux return mux

View File

@@ -2,7 +2,9 @@ package whatshooked
import ( import (
"context" "context"
"time"
"git.warky.dev/wdevs/whatshooked/pkg/cache"
"git.warky.dev/wdevs/whatshooked/pkg/config" "git.warky.dev/wdevs/whatshooked/pkg/config"
"git.warky.dev/wdevs/whatshooked/pkg/eventlogger" "git.warky.dev/wdevs/whatshooked/pkg/eventlogger"
"git.warky.dev/wdevs/whatshooked/pkg/events" "git.warky.dev/wdevs/whatshooked/pkg/events"
@@ -20,6 +22,7 @@ type WhatsHooked struct {
whatsappMgr *whatsapp.Manager whatsappMgr *whatsapp.Manager
hookMgr *hooks.Manager hookMgr *hooks.Manager
eventLogger *eventlogger.Logger eventLogger *eventlogger.Logger
messageCache *cache.MessageCache
handlers *handlers.Handlers handlers *handlers.Handlers
server *Server // Optional built-in server server *Server // Optional built-in server
} }
@@ -84,14 +87,30 @@ func newWithConfig(cfg *config.Config, configPath string) (*WhatsHooked, error)
}, },
) )
// Initialize message cache
cacheConfig := cache.Config{
Enabled: cfg.MessageCache.Enabled,
DataPath: cfg.MessageCache.DataPath,
MaxAge: time.Duration(cfg.MessageCache.MaxAgeDays) * 24 * time.Hour,
MaxEvents: cfg.MessageCache.MaxEvents,
}
messageCache, err := cache.NewMessageCache(cacheConfig)
if err != nil {
logging.Error("Failed to initialize message cache", "error", err)
// Continue without cache rather than failing
messageCache = &cache.MessageCache{}
}
wh.messageCache = messageCache
// Initialize hook manager // Initialize hook manager
wh.hookMgr = hooks.NewManager(wh.eventBus) wh.hookMgr = hooks.NewManager(wh.eventBus, wh.messageCache)
wh.hookMgr.LoadHooks(cfg.Hooks) wh.hookMgr.LoadHooks(cfg.Hooks)
wh.hookMgr.Start() wh.hookMgr.Start()
// 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) {