Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98fc28fc5f | ||
|
|
c5e121de4a | ||
|
|
c4d974d6ce | ||
|
|
3901bbb668 | ||
| 147dac9b60 | |||
| d80a6433b9 | |||
| 7b2390cbf6 | |||
| eb788f903a | |||
| 4d083b0bd9 |
5
.github/workflows/ci.yml
vendored
5
.github/workflows/ci.yml
vendored
@@ -34,6 +34,11 @@ jobs:
|
|||||||
- name: Download dependencies
|
- name: Download dependencies
|
||||||
run: go mod download
|
run: go mod download
|
||||||
|
|
||||||
|
- name: Clean build cache
|
||||||
|
run: |
|
||||||
|
go clean -cache
|
||||||
|
go mod tidy
|
||||||
|
|
||||||
- name: Run unit tests
|
- name: Run unit tests
|
||||||
run: make test
|
run: make test
|
||||||
|
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -47,3 +47,6 @@ sessions/
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
/server
|
/server
|
||||||
|
|
||||||
|
# Web directory (files are embedded in pkg/handlers/static/)
|
||||||
|
web/
|
||||||
|
|||||||
430
ACCOUNT_MANAGEMENT.md
Normal file
430
ACCOUNT_MANAGEMENT.md
Normal file
@@ -0,0 +1,430 @@
|
|||||||
|
# Account Management API
|
||||||
|
|
||||||
|
This document describes the API endpoints for managing WhatsApp accounts in WhatsHooked.
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
All account management endpoints require authentication using one of the following methods:
|
||||||
|
|
||||||
|
- **API Key**: `X-API-Key: your-api-key-here`
|
||||||
|
- **Basic Auth**: `Authorization: Basic <base64(username:password)>`
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
### 1. List All Accounts
|
||||||
|
|
||||||
|
Get a list of all configured WhatsApp accounts.
|
||||||
|
|
||||||
|
**Endpoint:** `GET /api/accounts`
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "business",
|
||||||
|
"type": "business-api",
|
||||||
|
"phone_number": "+27663602295",
|
||||||
|
"disabled": false,
|
||||||
|
"business_api": {
|
||||||
|
"phone_number_id": "889966060876308",
|
||||||
|
"access_token": "...",
|
||||||
|
"business_account_id": "1602055757491196",
|
||||||
|
"api_version": "v21.0",
|
||||||
|
"verify_token": "..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Add Account
|
||||||
|
|
||||||
|
Add a new WhatsApp account to the system.
|
||||||
|
|
||||||
|
**Endpoint:** `POST /api/accounts/add`
|
||||||
|
|
||||||
|
**Request Body (WhatsApp Web/WhatsMe ow):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "my-account",
|
||||||
|
"type": "whatsmeow",
|
||||||
|
"phone_number": "+1234567890",
|
||||||
|
"session_path": "./sessions/my-account",
|
||||||
|
"show_qr": true,
|
||||||
|
"disabled": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request Body (Business API):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "business-account",
|
||||||
|
"type": "business-api",
|
||||||
|
"phone_number": "+1234567890",
|
||||||
|
"disabled": false,
|
||||||
|
"business_api": {
|
||||||
|
"phone_number_id": "123456789",
|
||||||
|
"access_token": "YOUR_ACCESS_TOKEN",
|
||||||
|
"business_account_id": "987654321",
|
||||||
|
"api_version": "v21.0",
|
||||||
|
"verify_token": "your-verify-token"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"account_id": "my-account"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Status Codes:**
|
||||||
|
- `201 Created` - Account added successfully
|
||||||
|
- `400 Bad Request` - Invalid request body
|
||||||
|
- `500 Internal Server Error` - Failed to connect or save config
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Update Account
|
||||||
|
|
||||||
|
Update an existing WhatsApp account configuration.
|
||||||
|
|
||||||
|
**Endpoint:** `POST /api/accounts/update` or `PUT /api/accounts/update`
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "business-account",
|
||||||
|
"type": "business-api",
|
||||||
|
"phone_number": "+1234567890",
|
||||||
|
"disabled": false,
|
||||||
|
"business_api": {
|
||||||
|
"phone_number_id": "123456789",
|
||||||
|
"access_token": "NEW_ACCESS_TOKEN",
|
||||||
|
"business_account_id": "987654321",
|
||||||
|
"api_version": "v21.0",
|
||||||
|
"verify_token": "new-verify-token"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"account_id": "business-account"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Notes:**
|
||||||
|
- The `id` field is required to identify which account to update
|
||||||
|
- The `type` field cannot be changed (preserved from original)
|
||||||
|
- If the account is enabled, it will be disconnected and reconnected with new settings
|
||||||
|
- Configuration is saved to disk after successful update
|
||||||
|
|
||||||
|
**Status Codes:**
|
||||||
|
- `200 OK` - Account updated successfully
|
||||||
|
- `400 Bad Request` - Invalid request or missing ID
|
||||||
|
- `404 Not Found` - Account not found
|
||||||
|
- `500 Internal Server Error` - Failed to reconnect or save config
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Disable Account
|
||||||
|
|
||||||
|
Disable a WhatsApp account (disconnect and prevent auto-connect on restart).
|
||||||
|
|
||||||
|
**Endpoint:** `POST /api/accounts/disable`
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "my-account"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"account_id": "my-account"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Behavior:**
|
||||||
|
- Disconnects the account immediately
|
||||||
|
- Sets `disabled: true` in config
|
||||||
|
- Account will not auto-connect on server restart
|
||||||
|
- Account remains in config (can be re-enabled)
|
||||||
|
|
||||||
|
**Status Codes:**
|
||||||
|
- `200 OK` - Account disabled successfully
|
||||||
|
- `400 Bad Request` - Invalid request body
|
||||||
|
- `404 Not Found` - Account not found
|
||||||
|
- `500 Internal Server Error` - Failed to save config
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. Enable Account
|
||||||
|
|
||||||
|
Enable a previously disabled WhatsApp account.
|
||||||
|
|
||||||
|
**Endpoint:** `POST /api/accounts/enable`
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "my-account"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"account_id": "my-account"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Behavior:**
|
||||||
|
- Sets `disabled: false` in config
|
||||||
|
- Connects the account immediately
|
||||||
|
- Account will auto-connect on server restart
|
||||||
|
- If connection fails, account remains disabled
|
||||||
|
|
||||||
|
**Status Codes:**
|
||||||
|
- `200 OK` - Account enabled and connected successfully
|
||||||
|
- `400 Bad Request` - Invalid request body
|
||||||
|
- `404 Not Found` - Account not found
|
||||||
|
- `500 Internal Server Error` - Failed to connect or save config
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. Remove Account
|
||||||
|
|
||||||
|
Permanently remove a WhatsApp account from the system.
|
||||||
|
|
||||||
|
**Endpoint:** `POST /api/accounts/remove` or `DELETE /api/accounts/remove`
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "my-account"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Behavior:**
|
||||||
|
- Disconnects the account
|
||||||
|
- Removes from config permanently
|
||||||
|
- Session data is NOT deleted (manual cleanup required)
|
||||||
|
|
||||||
|
**Status Codes:**
|
||||||
|
- `200 OK` - Account removed successfully
|
||||||
|
- `400 Bad Request` - Invalid request body
|
||||||
|
- `404 Not Found` - Account not found
|
||||||
|
- `500 Internal Server Error` - Failed to save config
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration File
|
||||||
|
|
||||||
|
Account settings are stored in `config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"whatsapp": [
|
||||||
|
{
|
||||||
|
"id": "business",
|
||||||
|
"type": "business-api",
|
||||||
|
"phone_number": "+27663602295",
|
||||||
|
"disabled": false,
|
||||||
|
"business_api": {
|
||||||
|
"phone_number_id": "889966060876308",
|
||||||
|
"access_token": "...",
|
||||||
|
"business_account_id": "1602055757491196",
|
||||||
|
"api_version": "v21.0",
|
||||||
|
"verify_token": "..."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "personal",
|
||||||
|
"type": "whatsmeow",
|
||||||
|
"phone_number": "+1234567890",
|
||||||
|
"session_path": "./sessions/personal",
|
||||||
|
"show_qr": true,
|
||||||
|
"disabled": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration Fields
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `id` | string | Yes | Unique identifier for the account |
|
||||||
|
| `type` | string | Yes | Account type: `whatsmeow` or `business-api` |
|
||||||
|
| `phone_number` | string | Yes | Phone number with country code |
|
||||||
|
| `disabled` | boolean | No | If `true`, account won't connect (default: `false`) |
|
||||||
|
| `session_path` | string | No | Session storage path (whatsmeow only) |
|
||||||
|
| `show_qr` | boolean | No | Display QR code in logs (whatsmeow only) |
|
||||||
|
| `business_api` | object | Conditional | Required for `business-api` type |
|
||||||
|
|
||||||
|
### Business API Configuration
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `phone_number_id` | string | Yes | WhatsApp Business phone number ID |
|
||||||
|
| `access_token` | string | Yes | Meta Graph API access token |
|
||||||
|
| `business_account_id` | string | No | Business account ID |
|
||||||
|
| `api_version` | string | No | API version (default: `v21.0`) |
|
||||||
|
| `verify_token` | string | No | Webhook verification token |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Usage Examples
|
||||||
|
|
||||||
|
### cURL Examples
|
||||||
|
|
||||||
|
**List accounts:**
|
||||||
|
```bash
|
||||||
|
curl -u username:password http://localhost:8080/api/accounts
|
||||||
|
```
|
||||||
|
|
||||||
|
**Add account:**
|
||||||
|
```bash
|
||||||
|
curl -u username:password \
|
||||||
|
-X POST \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"id": "new-account",
|
||||||
|
"type": "whatsmeow",
|
||||||
|
"phone_number": "+1234567890",
|
||||||
|
"session_path": "./sessions/new-account",
|
||||||
|
"show_qr": true
|
||||||
|
}' \
|
||||||
|
http://localhost:8080/api/accounts/add
|
||||||
|
```
|
||||||
|
|
||||||
|
**Update account:**
|
||||||
|
```bash
|
||||||
|
curl -u username:password \
|
||||||
|
-X POST \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"id": "business",
|
||||||
|
"type": "business-api",
|
||||||
|
"phone_number": "+27663602295",
|
||||||
|
"business_api": {
|
||||||
|
"phone_number_id": "889966060876308",
|
||||||
|
"access_token": "NEW_TOKEN_HERE",
|
||||||
|
"api_version": "v21.0"
|
||||||
|
}
|
||||||
|
}' \
|
||||||
|
http://localhost:8080/api/accounts/update
|
||||||
|
```
|
||||||
|
|
||||||
|
**Disable account:**
|
||||||
|
```bash
|
||||||
|
curl -u username:password \
|
||||||
|
-X POST \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"id": "my-account"}' \
|
||||||
|
http://localhost:8080/api/accounts/disable
|
||||||
|
```
|
||||||
|
|
||||||
|
**Enable account:**
|
||||||
|
```bash
|
||||||
|
curl -u username:password \
|
||||||
|
-X POST \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"id": "my-account"}' \
|
||||||
|
http://localhost:8080/api/accounts/enable
|
||||||
|
```
|
||||||
|
|
||||||
|
**Remove account:**
|
||||||
|
```bash
|
||||||
|
curl -u username:password \
|
||||||
|
-X POST \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"id": "my-account"}' \
|
||||||
|
http://localhost:8080/api/accounts/remove
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Server Logs
|
||||||
|
|
||||||
|
When managing accounts, you'll see these log messages:
|
||||||
|
|
||||||
|
```log
|
||||||
|
INFO Account disabled account_id=business
|
||||||
|
INFO Config saved after disabling account account_id=business
|
||||||
|
|
||||||
|
INFO Account enabled and connected account_id=business
|
||||||
|
INFO Config saved after enabling account account_id=business
|
||||||
|
|
||||||
|
INFO Account configuration updated account_id=business
|
||||||
|
INFO Account reconnected with new settings account_id=business
|
||||||
|
|
||||||
|
INFO Skipping disabled account account_id=business
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Disable vs Remove**:
|
||||||
|
- Use **disable** for temporary disconnection (maintenance, testing)
|
||||||
|
- Use **remove** for permanent deletion
|
||||||
|
|
||||||
|
2. **Update Process**:
|
||||||
|
- Always provide complete configuration when updating
|
||||||
|
- Account will be reconnected automatically if enabled
|
||||||
|
|
||||||
|
3. **Session Management**:
|
||||||
|
- WhatsMe ow sessions are stored in `session_path`
|
||||||
|
- Removing an account doesn't delete session files
|
||||||
|
- Clean up manually if needed
|
||||||
|
|
||||||
|
4. **Server Restart**:
|
||||||
|
- Only enabled accounts (`disabled: false`) connect on startup
|
||||||
|
- Disabled accounts remain in config but stay disconnected
|
||||||
|
|
||||||
|
5. **Configuration Backup**:
|
||||||
|
- Config is automatically saved after each change
|
||||||
|
- Keep backups before making bulk changes
|
||||||
|
- Test changes with one account first
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
All endpoints return proper HTTP status codes and JSON error messages:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "Account not found"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Or plain text for simple errors:
|
||||||
|
```
|
||||||
|
Account ID required in path
|
||||||
|
```
|
||||||
|
|
||||||
|
Common error scenarios:
|
||||||
|
- Account already exists (when adding)
|
||||||
|
- Account not found (when updating/removing)
|
||||||
|
- Connection failed (when enabling)
|
||||||
|
- Configuration save failed (any operation)
|
||||||
@@ -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
|
||||||
|
|||||||
1040
WHATSAPP_BUSINESS.md
Normal file
1040
WHATSAPP_BUSINESS.md
Normal file
File diff suppressed because it is too large
Load Diff
8
go.mod
8
go.mod
@@ -30,20 +30,20 @@ require (
|
|||||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
github.com/petermattis/goid v0.0.0-20251121121749-a11dd1a45f9a // indirect
|
github.com/petermattis/goid v0.0.0-20251121121749-a11dd1a45f9a // indirect
|
||||||
github.com/rs/zerolog v1.34.0 // indirect
|
github.com/rs/zerolog v1.34.0 // indirect
|
||||||
github.com/sagikazarmark/locafero v0.11.0 // indirect
|
github.com/sagikazarmark/locafero v0.12.0 // indirect
|
||||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
|
||||||
github.com/spf13/afero v1.15.0 // indirect
|
github.com/spf13/afero v1.15.0 // indirect
|
||||||
github.com/spf13/cast v1.10.0 // indirect
|
github.com/spf13/cast v1.10.0 // indirect
|
||||||
github.com/spf13/pflag v1.0.10 // indirect
|
github.com/spf13/pflag v1.0.10 // indirect
|
||||||
github.com/subosito/gotenv v1.6.0 // indirect
|
github.com/subosito/gotenv v1.6.0 // indirect
|
||||||
github.com/vektah/gqlparser/v2 v2.5.27 // indirect
|
github.com/vektah/gqlparser/v2 v2.5.31 // indirect
|
||||||
go.mau.fi/libsignal v0.2.1 // indirect
|
go.mau.fi/libsignal v0.2.1 // indirect
|
||||||
go.mau.fi/util v0.9.4 // indirect
|
go.mau.fi/util v0.9.4 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 // indirect
|
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 // indirect
|
||||||
golang.org/x/net v0.48.0 // indirect
|
golang.org/x/net v0.48.0 // indirect
|
||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.39.0 // indirect
|
golang.org/x/sys v0.39.0 // indirect
|
||||||
golang.org/x/term v0.38.0 // indirect
|
golang.org/x/term v0.38.0 // indirect
|
||||||
golang.org/x/text v0.32.0 // indirect
|
golang.org/x/text v0.32.0 // indirect
|
||||||
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
14
go.sum
14
go.sum
@@ -63,12 +63,10 @@ github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
|||||||
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
||||||
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
|
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
|
||||||
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
|
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
|
||||||
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
|
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
|
||||||
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
|
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
|
||||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
|
|
||||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
|
|
||||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||||
@@ -84,8 +82,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
|||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||||
github.com/vektah/gqlparser/v2 v2.5.27 h1:RHPD3JOplpk5mP5JGX8RKZkt2/Vwj/PZv0HxTdwFp0s=
|
github.com/vektah/gqlparser/v2 v2.5.31 h1:YhWGA1mfTjID7qJhd1+Vxhpk5HTgydrGU9IgkWBTJ7k=
|
||||||
github.com/vektah/gqlparser/v2 v2.5.27/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo=
|
github.com/vektah/gqlparser/v2 v2.5.31/go.mod h1:c1I28gSOVNzlfc4WuDlqU7voQnsqI6OG2amkBAFmgts=
|
||||||
go.mau.fi/libsignal v0.2.1 h1:vRZG4EzTn70XY6Oh/pVKrQGuMHBkAWlGRC22/85m9L0=
|
go.mau.fi/libsignal v0.2.1 h1:vRZG4EzTn70XY6Oh/pVKrQGuMHBkAWlGRC22/85m9L0=
|
||||||
go.mau.fi/libsignal v0.2.1/go.mod h1:iVvjrHyfQqWajOUaMEsIfo3IqgVMrhWcPiiEzk7NgoU=
|
go.mau.fi/libsignal v0.2.1/go.mod h1:iVvjrHyfQqWajOUaMEsIfo3IqgVMrhWcPiiEzk7NgoU=
|
||||||
go.mau.fi/util v0.9.4 h1:gWdUff+K2rCynRPysXalqqQyr2ahkSWaestH6YhSpso=
|
go.mau.fi/util v0.9.4 h1:gWdUff+K2rCynRPysXalqqQyr2ahkSWaestH6YhSpso=
|
||||||
@@ -96,8 +94,8 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
|||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||||
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||||
golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 h1:MDfG8Cvcqlt9XXrmEiD4epKn7VJHZO84hejP9Jmp0MM=
|
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0=
|
||||||
golang.org/x/exp v0.0.0-20251209150349-8475f28825e9/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
|
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
|
||||||
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
|
|||||||
394
pkg/cache/message_cache.go
vendored
Normal file
394
pkg/cache/message_cache.go
vendored
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,6 +52,7 @@ type WhatsAppConfig struct {
|
|||||||
PhoneNumber string `json:"phone_number"`
|
PhoneNumber string `json:"phone_number"`
|
||||||
SessionPath string `json:"session_path,omitempty"`
|
SessionPath string `json:"session_path,omitempty"`
|
||||||
ShowQR bool `json:"show_qr,omitempty"`
|
ShowQR bool `json:"show_qr,omitempty"`
|
||||||
|
Disabled bool `json:"disabled,omitempty"` // If true, account won't be connected
|
||||||
BusinessAPI *BusinessAPIConfig `json:"business_api,omitempty"`
|
BusinessAPI *BusinessAPIConfig `json:"business_api,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,6 +124,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 +196,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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -98,3 +98,197 @@ func (h *Handlers) RemoveAccount(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
writeJSON(w, map[string]string{"status": "ok"})
|
writeJSON(w, map[string]string{"status": "ok"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DisableAccount disables a WhatsApp account
|
||||||
|
func (h *Handlers) DisableAccount(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the account
|
||||||
|
found := false
|
||||||
|
for i := range h.config.WhatsApp {
|
||||||
|
if h.config.WhatsApp[i].ID == req.ID {
|
||||||
|
found = true
|
||||||
|
|
||||||
|
// Check if already disabled
|
||||||
|
if h.config.WhatsApp[i].Disabled {
|
||||||
|
logging.Info("Account already disabled", "account_id", req.ID)
|
||||||
|
writeJSON(w, map[string]string{"status": "ok", "message": "account already disabled"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disconnect the account
|
||||||
|
if err := h.whatsappMgr.Disconnect(req.ID); err != nil {
|
||||||
|
logging.Warn("Failed to disconnect account during disable", "account_id", req.ID, "error", err)
|
||||||
|
// Continue with disabling even if disconnect fails
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as disabled
|
||||||
|
h.config.WhatsApp[i].Disabled = true
|
||||||
|
logging.Info("Account disabled", "account_id", req.ID)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
http.Error(w, "Account not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save config
|
||||||
|
if h.configPath != "" {
|
||||||
|
if err := config.Save(h.configPath, h.config); err != nil {
|
||||||
|
logging.Error("Failed to save config after disabling account", "account_id", req.ID, "error", err)
|
||||||
|
http.Error(w, "Failed to save configuration", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logging.Info("Config saved after disabling account", "account_id", req.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, map[string]string{"status": "ok", "account_id": req.ID})
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnableAccount enables a WhatsApp account
|
||||||
|
func (h *Handlers) EnableAccount(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the account
|
||||||
|
var accountConfig *config.WhatsAppConfig
|
||||||
|
for i := range h.config.WhatsApp {
|
||||||
|
if h.config.WhatsApp[i].ID == req.ID {
|
||||||
|
accountConfig = &h.config.WhatsApp[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if accountConfig == nil {
|
||||||
|
http.Error(w, "Account not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already enabled
|
||||||
|
if !accountConfig.Disabled {
|
||||||
|
logging.Info("Account already enabled", "account_id", req.ID)
|
||||||
|
writeJSON(w, map[string]string{"status": "ok", "message": "account already enabled"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as enabled
|
||||||
|
accountConfig.Disabled = false
|
||||||
|
|
||||||
|
// Connect the account
|
||||||
|
if err := h.whatsappMgr.Connect(context.Background(), *accountConfig); err != nil {
|
||||||
|
logging.Error("Failed to connect account during enable", "account_id", req.ID, "error", err)
|
||||||
|
// Revert the disabled flag
|
||||||
|
accountConfig.Disabled = true
|
||||||
|
http.Error(w, "Failed to connect account: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logging.Info("Account enabled and connected", "account_id", req.ID)
|
||||||
|
|
||||||
|
// Save config
|
||||||
|
if h.configPath != "" {
|
||||||
|
if err := config.Save(h.configPath, h.config); err != nil {
|
||||||
|
logging.Error("Failed to save config after enabling account", "account_id", req.ID, "error", err)
|
||||||
|
http.Error(w, "Failed to save configuration", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logging.Info("Config saved after enabling account", "account_id", req.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, map[string]string{"status": "ok", "account_id": req.ID})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateAccount updates an existing WhatsApp account configuration
|
||||||
|
func (h *Handlers) UpdateAccount(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost && r.Method != http.MethodPut {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var updates config.WhatsAppConfig
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if updates.ID == "" {
|
||||||
|
http.Error(w, "Account ID is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the account
|
||||||
|
found := false
|
||||||
|
var oldConfig config.WhatsAppConfig
|
||||||
|
for i := range h.config.WhatsApp {
|
||||||
|
if h.config.WhatsApp[i].ID == updates.ID {
|
||||||
|
found = true
|
||||||
|
oldConfig = h.config.WhatsApp[i]
|
||||||
|
|
||||||
|
// Update fields (preserve ID and Type)
|
||||||
|
updates.ID = oldConfig.ID
|
||||||
|
if updates.Type == "" {
|
||||||
|
updates.Type = oldConfig.Type
|
||||||
|
}
|
||||||
|
|
||||||
|
h.config.WhatsApp[i] = updates
|
||||||
|
logging.Info("Account configuration updated", "account_id", updates.ID)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
http.Error(w, "Account not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the account was enabled and settings changed, reconnect it
|
||||||
|
if !updates.Disabled {
|
||||||
|
// Disconnect old connection
|
||||||
|
if err := h.whatsappMgr.Disconnect(updates.ID); err != nil {
|
||||||
|
logging.Warn("Failed to disconnect account during update", "account_id", updates.ID, "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconnect with new settings
|
||||||
|
if err := h.whatsappMgr.Connect(context.Background(), updates); err != nil {
|
||||||
|
logging.Error("Failed to reconnect account after update", "account_id", updates.ID, "error", err)
|
||||||
|
http.Error(w, "Failed to reconnect account: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logging.Info("Account reconnected with new settings", "account_id", updates.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save config
|
||||||
|
if h.configPath != "" {
|
||||||
|
if err := config.Save(h.configPath, h.config); err != nil {
|
||||||
|
logging.Error("Failed to save config after updating account", "account_id", updates.ID, "error", err)
|
||||||
|
http.Error(w, "Failed to save configuration", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logging.Info("Config saved after updating account", "account_id", updates.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, map[string]string{"status": "ok", "account_id": updates.ID})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
254
pkg/handlers/cache.go
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
66
pkg/handlers/static.go
Normal file
66
pkg/handlers/static.go
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"git.warky.dev/wdevs/whatshooked/pkg/logging"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed static/*
|
||||||
|
var staticFiles embed.FS
|
||||||
|
|
||||||
|
// ServeIndex serves the landing page
|
||||||
|
func (h *Handlers) ServeIndex(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Only serve index on root path
|
||||||
|
if r.URL.Path != "/" {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := staticFiles.ReadFile("static/index.html")
|
||||||
|
if err != nil {
|
||||||
|
logging.Error("Failed to read index.html", "error", err)
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
writeBytes(w, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeStatic serves static files (logo, etc.)
|
||||||
|
func (h *Handlers) ServeStatic(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Get the file path from URL
|
||||||
|
filename := filepath.Base(r.URL.Path)
|
||||||
|
filePath := filepath.Join("static", filename)
|
||||||
|
|
||||||
|
content, err := staticFiles.ReadFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
logging.Error("Failed to read static file", "path", filePath, "error", err)
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set content type based on file extension
|
||||||
|
ext := filepath.Ext(filePath)
|
||||||
|
switch ext {
|
||||||
|
case ".png":
|
||||||
|
w.Header().Set("Content-Type", "image/png")
|
||||||
|
case ".jpg", ".jpeg":
|
||||||
|
w.Header().Set("Content-Type", "image/jpeg")
|
||||||
|
case ".svg":
|
||||||
|
w.Header().Set("Content-Type", "image/svg+xml")
|
||||||
|
case ".css":
|
||||||
|
w.Header().Set("Content-Type", "text/css")
|
||||||
|
case ".js":
|
||||||
|
w.Header().Set("Content-Type", "application/javascript")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache static assets for 1 hour
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
writeBytes(w, content)
|
||||||
|
}
|
||||||
82
pkg/handlers/static/README.md
Normal file
82
pkg/handlers/static/README.md
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
# Static Files
|
||||||
|
|
||||||
|
This directory contains the embedded static files for the WhatsHooked landing page.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `index.html` - Landing page with API documentation
|
||||||
|
- `logo.png` - WhatsHooked logo (from `assets/image/whatshooked_tp.png`)
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
These files are embedded into the Go binary using `go:embed` directive in `static.go`.
|
||||||
|
|
||||||
|
When you build the server:
|
||||||
|
```bash
|
||||||
|
go build ./cmd/server/
|
||||||
|
```
|
||||||
|
|
||||||
|
The files in this directory are compiled directly into the binary, so the server can run without any external files.
|
||||||
|
|
||||||
|
## Updating the Landing Page
|
||||||
|
|
||||||
|
1. **Edit the HTML:**
|
||||||
|
```bash
|
||||||
|
vim pkg/handlers/static/index.html
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Rebuild the server:**
|
||||||
|
```bash
|
||||||
|
go build ./cmd/server/
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Restart the server:**
|
||||||
|
```bash
|
||||||
|
./server -config bin/config.json
|
||||||
|
```
|
||||||
|
|
||||||
|
The changes will be embedded in the new binary.
|
||||||
|
|
||||||
|
## Updating the Logo
|
||||||
|
|
||||||
|
1. **Replace the logo:**
|
||||||
|
```bash
|
||||||
|
cp path/to/new-logo.png pkg/handlers/static/logo.png
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Rebuild:**
|
||||||
|
```bash
|
||||||
|
go build ./cmd/server/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Routes
|
||||||
|
|
||||||
|
- `GET /` - Serves `index.html`
|
||||||
|
- `GET /static/logo.png` - Serves `logo.png`
|
||||||
|
- `GET /static/*` - Serves any file in this directory
|
||||||
|
|
||||||
|
## Development Tips
|
||||||
|
|
||||||
|
- Files are cached with `Cache-Control: public, max-age=3600` (1 hour)
|
||||||
|
- Force refresh in browser: `Ctrl+Shift+R` or `Cmd+Shift+R`
|
||||||
|
- Changes require rebuild - no hot reload
|
||||||
|
- Keep files small - they're embedded in the binary
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
pkg/handlers/
|
||||||
|
├── static.go # Handler with go:embed directive
|
||||||
|
├── static/
|
||||||
|
│ ├── index.html # Landing page
|
||||||
|
│ ├── logo.png # Logo image
|
||||||
|
│ └── README.md # This file
|
||||||
|
```
|
||||||
|
|
||||||
|
## Benefits of Embedded Files
|
||||||
|
|
||||||
|
✅ **Single binary deployment** - No external dependencies
|
||||||
|
✅ **Fast serving** - Files loaded from memory
|
||||||
|
✅ **No file system access** - Works in restricted environments
|
||||||
|
✅ **Portable** - Binary includes everything
|
||||||
|
✅ **Version controlled** - Static assets tracked with code
|
||||||
424
pkg/handlers/static/index.html
Normal file
424
pkg/handlers/static/index.html
Normal file
@@ -0,0 +1,424 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>WhatsHooked - WhatsApp Webhook Bridge</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
background: white;
|
||||||
|
border-radius: 20px;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||||
|
max-width: 800px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 60px 40px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-container {
|
||||||
|
margin-bottom: 40px;
|
||||||
|
animation: fadeInDown 0.8s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
width: 200px;
|
||||||
|
height: 200px;
|
||||||
|
margin: 0 auto 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 3em;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
animation: fadeInDown 0.8s ease 0.2s both;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 .whats {
|
||||||
|
color: #1e88e5;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 .hooked {
|
||||||
|
color: #1a237e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagline {
|
||||||
|
font-size: 1.3em;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
animation: fadeInUp 0.8s ease 0.4s both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
background: #e8f5e9;
|
||||||
|
border: 2px solid #4caf50;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
animation: fadeInUp 0.8s ease 0.6s both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator {
|
||||||
|
display: inline-block;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
background: #4caf50;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-right: 10px;
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-text {
|
||||||
|
color: #2e7d32;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.features {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
animation: fadeInUp 0.8s ease 0.8s both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature {
|
||||||
|
background: #f5f5f5;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature:hover {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-icon {
|
||||||
|
font-size: 2em;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-title {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: #1a237e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-text {
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.endpoints {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 30px;
|
||||||
|
text-align: left;
|
||||||
|
animation: fadeInUp 0.8s ease 1s both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.endpoints h2 {
|
||||||
|
color: #1a237e;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.endpoint-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.endpoint-group h3 {
|
||||||
|
color: #1e88e5;
|
||||||
|
font-size: 1em;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.endpoint {
|
||||||
|
background: white;
|
||||||
|
padding: 12px 15px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-family: 'Monaco', 'Courier New', monospace;
|
||||||
|
font-size: 0.85em;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.endpoint-method {
|
||||||
|
background: #1e88e5;
|
||||||
|
color: white;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.85em;
|
||||||
|
min-width: 60px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.endpoint-method.post {
|
||||||
|
background: #4caf50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.endpoint-method.delete {
|
||||||
|
background: #f44336;
|
||||||
|
}
|
||||||
|
|
||||||
|
.endpoint-path {
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
margin-top: 40px;
|
||||||
|
padding-top: 30px;
|
||||||
|
border-top: 2px solid #eee;
|
||||||
|
color: #999;
|
||||||
|
font-size: 0.9em;
|
||||||
|
animation: fadeIn 0.8s ease 1.2s both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer a {
|
||||||
|
color: #1e88e5;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInDown {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.container {
|
||||||
|
padding: 40px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagline {
|
||||||
|
font-size: 1.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
width: 150px;
|
||||||
|
height: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.endpoint {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="logo-container">
|
||||||
|
<div class="logo">
|
||||||
|
<img src="/static/logo.png" alt="WhatsHooked Logo">
|
||||||
|
</div>
|
||||||
|
<h1><span class="whats">Whats</span><span class="hooked">Hooked</span></h1>
|
||||||
|
<p class="tagline">Bridge your WhatsApp messages to webhooks</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="status">
|
||||||
|
<span class="status-indicator"></span>
|
||||||
|
<span class="status-text">Server is running</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="features">
|
||||||
|
<div class="feature">
|
||||||
|
<div class="feature-icon">📱</div>
|
||||||
|
<div class="feature-title">WhatsApp Integration</div>
|
||||||
|
<div class="feature-text">Connect via WhatsApp Web or Business API</div>
|
||||||
|
</div>
|
||||||
|
<div class="feature">
|
||||||
|
<div class="feature-icon">🔗</div>
|
||||||
|
<div class="feature-title">Webhook Bridge</div>
|
||||||
|
<div class="feature-text">Forward messages to your custom endpoints</div>
|
||||||
|
</div>
|
||||||
|
<div class="feature">
|
||||||
|
<div class="feature-icon">⚡</div>
|
||||||
|
<div class="feature-title">Real-time Events</div>
|
||||||
|
<div class="feature-text">Instant message delivery and status updates</div>
|
||||||
|
</div>
|
||||||
|
<div class="feature">
|
||||||
|
<div class="feature-icon">💾</div>
|
||||||
|
<div class="feature-title">Message Cache</div>
|
||||||
|
<div class="feature-text">Never lose messages with persistent storage</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="endpoints">
|
||||||
|
<h2>Available API Endpoints</h2>
|
||||||
|
|
||||||
|
<div class="endpoint-group">
|
||||||
|
<h3>📊 Status & Health</h3>
|
||||||
|
<div class="endpoint">
|
||||||
|
<span class="endpoint-method">GET</span>
|
||||||
|
<span class="endpoint-path">/health</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="endpoint-group">
|
||||||
|
<h3>🔌 Webhooks</h3>
|
||||||
|
<div class="endpoint">
|
||||||
|
<span class="endpoint-method">GET</span>
|
||||||
|
<span class="endpoint-path">/api/hooks</span>
|
||||||
|
</div>
|
||||||
|
<div class="endpoint">
|
||||||
|
<span class="endpoint-method post">POST</span>
|
||||||
|
<span class="endpoint-path">/api/hooks/add</span>
|
||||||
|
</div>
|
||||||
|
<div class="endpoint">
|
||||||
|
<span class="endpoint-method delete">DELETE</span>
|
||||||
|
<span class="endpoint-path">/api/hooks/remove</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="endpoint-group">
|
||||||
|
<h3>👤 Accounts</h3>
|
||||||
|
<div class="endpoint">
|
||||||
|
<span class="endpoint-method">GET</span>
|
||||||
|
<span class="endpoint-path">/api/accounts</span>
|
||||||
|
</div>
|
||||||
|
<div class="endpoint">
|
||||||
|
<span class="endpoint-method post">POST</span>
|
||||||
|
<span class="endpoint-path">/api/accounts/add</span>
|
||||||
|
</div>
|
||||||
|
<div class="endpoint">
|
||||||
|
<span class="endpoint-method post">POST</span>
|
||||||
|
<span class="endpoint-path">/api/accounts/update</span>
|
||||||
|
</div>
|
||||||
|
<div class="endpoint">
|
||||||
|
<span class="endpoint-method post">POST</span>
|
||||||
|
<span class="endpoint-path">/api/accounts/disable</span>
|
||||||
|
</div>
|
||||||
|
<div class="endpoint">
|
||||||
|
<span class="endpoint-method post">POST</span>
|
||||||
|
<span class="endpoint-path">/api/accounts/enable</span>
|
||||||
|
</div>
|
||||||
|
<div class="endpoint">
|
||||||
|
<span class="endpoint-method delete">DELETE</span>
|
||||||
|
<span class="endpoint-path">/api/accounts/remove</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="endpoint-group">
|
||||||
|
<h3>💬 Send Messages</h3>
|
||||||
|
<div class="endpoint">
|
||||||
|
<span class="endpoint-method post">POST</span>
|
||||||
|
<span class="endpoint-path">/api/send</span>
|
||||||
|
</div>
|
||||||
|
<div class="endpoint">
|
||||||
|
<span class="endpoint-method post">POST</span>
|
||||||
|
<span class="endpoint-path">/api/send/image</span>
|
||||||
|
</div>
|
||||||
|
<div class="endpoint">
|
||||||
|
<span class="endpoint-method post">POST</span>
|
||||||
|
<span class="endpoint-path">/api/send/video</span>
|
||||||
|
</div>
|
||||||
|
<div class="endpoint">
|
||||||
|
<span class="endpoint-method post">POST</span>
|
||||||
|
<span class="endpoint-path">/api/send/document</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="endpoint-group">
|
||||||
|
<h3>💾 Message Cache</h3>
|
||||||
|
<div class="endpoint">
|
||||||
|
<span class="endpoint-method">GET</span>
|
||||||
|
<span class="endpoint-path">/api/cache</span>
|
||||||
|
</div>
|
||||||
|
<div class="endpoint">
|
||||||
|
<span class="endpoint-method">GET</span>
|
||||||
|
<span class="endpoint-path">/api/cache/stats</span>
|
||||||
|
</div>
|
||||||
|
<div class="endpoint">
|
||||||
|
<span class="endpoint-method post">POST</span>
|
||||||
|
<span class="endpoint-path">/api/cache/replay</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="endpoint-group">
|
||||||
|
<h3>🔔 WhatsApp Business API</h3>
|
||||||
|
<div class="endpoint">
|
||||||
|
<span class="endpoint-method">GET</span>
|
||||||
|
<span class="endpoint-path">/webhooks/whatsapp/{account_id}</span>
|
||||||
|
</div>
|
||||||
|
<div class="endpoint">
|
||||||
|
<span class="endpoint-method post">POST</span>
|
||||||
|
<span class="endpoint-path">/webhooks/whatsapp/{account_id}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<p>Need help? Check out the <a href="https://git.warky.dev/wdevs/whatshooked" target="_blank">documentation</a></p>
|
||||||
|
<p style="margin-top: 10px;">Made with ❤️ for developers</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
BIN
pkg/handlers/static/logo.png
Normal file
BIN
pkg/handlers/static/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 184 KiB |
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -68,38 +68,87 @@ func NewClient(cfg config.WhatsAppConfig, eventBus *events.EventBus, mediaConfig
|
|||||||
|
|
||||||
// Connect validates the Business API credentials
|
// Connect validates the Business API credentials
|
||||||
func (c *Client) Connect(ctx context.Context) error {
|
func (c *Client) Connect(ctx context.Context) error {
|
||||||
// Validate credentials by making a test request to get phone number details
|
logging.Info("Validating WhatsApp Business API credentials", "account_id", c.id)
|
||||||
url := fmt.Sprintf("https://graph.facebook.com/%s/%s",
|
|
||||||
c.config.APIVersion,
|
|
||||||
c.config.PhoneNumberID)
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
// Step 1: Validate token and check permissions
|
||||||
if err != nil {
|
tokenInfo, err := c.validateToken(ctx)
|
||||||
return fmt.Errorf("failed to create request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Header.Set("Authorization", "Bearer "+c.config.AccessToken)
|
|
||||||
|
|
||||||
resp, err := c.httpClient.Do(req)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.eventBus.Publish(events.WhatsAppPairFailedEvent(ctx, c.id, err))
|
c.eventBus.Publish(events.WhatsAppPairFailedEvent(ctx, c.id, err))
|
||||||
return fmt.Errorf("failed to validate credentials: %w", err)
|
return fmt.Errorf("token validation failed: %w", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
// Log token information
|
||||||
body, _ := io.ReadAll(resp.Body)
|
logging.Info("Access token validated",
|
||||||
err := fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(body))
|
"account_id", c.id,
|
||||||
|
"token_type", tokenInfo.Type,
|
||||||
|
"app", tokenInfo.Application,
|
||||||
|
"app_id", tokenInfo.AppID,
|
||||||
|
"expires", c.formatExpiry(tokenInfo.ExpiresAt),
|
||||||
|
"scopes", strings.Join(tokenInfo.Scopes, ", "))
|
||||||
|
|
||||||
|
// Check for required permissions
|
||||||
|
requiredScopes := []string{"whatsapp_business_management", "whatsapp_business_messaging"}
|
||||||
|
missingScopes := c.checkMissingScopes(tokenInfo.Scopes, requiredScopes)
|
||||||
|
if len(missingScopes) > 0 {
|
||||||
|
err := fmt.Errorf("token missing required permissions: %s", strings.Join(missingScopes, ", "))
|
||||||
|
logging.Error("Insufficient token permissions",
|
||||||
|
"account_id", c.id,
|
||||||
|
"missing_scopes", strings.Join(missingScopes, ", "),
|
||||||
|
"current_scopes", strings.Join(tokenInfo.Scopes, ", "))
|
||||||
c.eventBus.Publish(events.WhatsAppPairFailedEvent(ctx, c.id, err))
|
c.eventBus.Publish(events.WhatsAppPairFailedEvent(ctx, c.id, err))
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Step 2: Get phone number details
|
||||||
|
phoneDetails, err := c.getPhoneNumberDetails(ctx)
|
||||||
|
if err != nil {
|
||||||
|
c.eventBus.Publish(events.WhatsAppPairFailedEvent(ctx, c.id, err))
|
||||||
|
return fmt.Errorf("failed to get phone number details: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log phone number information
|
||||||
|
logging.Info("Phone number details retrieved",
|
||||||
|
"account_id", c.id,
|
||||||
|
"phone_number_id", phoneDetails.ID,
|
||||||
|
"display_number", phoneDetails.DisplayPhoneNumber,
|
||||||
|
"verified_name", phoneDetails.VerifiedName,
|
||||||
|
"verification_status", phoneDetails.CodeVerificationStatus,
|
||||||
|
"quality_rating", phoneDetails.QualityRating,
|
||||||
|
"throughput_level", phoneDetails.Throughput.Level)
|
||||||
|
|
||||||
|
// Warn if phone number is not verified
|
||||||
|
if phoneDetails.CodeVerificationStatus != "VERIFIED" {
|
||||||
|
logging.Warn("Phone number is not verified - messaging capabilities may be limited",
|
||||||
|
"account_id", c.id,
|
||||||
|
"status", phoneDetails.CodeVerificationStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Get business account details (if business_account_id is provided)
|
||||||
|
if c.config.BusinessAccountID != "" {
|
||||||
|
businessDetails, err := c.getBusinessAccountDetails(ctx)
|
||||||
|
if err != nil {
|
||||||
|
logging.Warn("Failed to get business account details (non-critical)",
|
||||||
|
"account_id", c.id,
|
||||||
|
"business_account_id", c.config.BusinessAccountID,
|
||||||
|
"error", err)
|
||||||
|
} else {
|
||||||
|
logging.Info("Business account details retrieved",
|
||||||
|
"account_id", c.id,
|
||||||
|
"business_account_id", businessDetails.ID,
|
||||||
|
"business_name", businessDetails.Name,
|
||||||
|
"timezone_id", businessDetails.TimezoneID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
c.connected = true
|
c.connected = true
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
|
|
||||||
logging.Info("Business API client connected", "account_id", c.id, "phone", c.phoneNumber)
|
logging.Info("Business API client connected successfully",
|
||||||
c.eventBus.Publish(events.WhatsAppConnectedEvent(ctx, c.id, c.phoneNumber))
|
"account_id", c.id,
|
||||||
|
"phone", phoneDetails.DisplayPhoneNumber,
|
||||||
|
"verified_name", phoneDetails.VerifiedName)
|
||||||
|
c.eventBus.Publish(events.WhatsAppConnectedEvent(ctx, c.id, phoneDetails.DisplayPhoneNumber))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,3 +389,154 @@ func jidToPhoneNumber(jid types.JID) string {
|
|||||||
|
|
||||||
return phone
|
return phone
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// validateToken validates the access token and returns token information
|
||||||
|
func (c *Client) validateToken(ctx context.Context) (*TokenDebugData, error) {
|
||||||
|
url := fmt.Sprintf("https://graph.facebook.com/%s/debug_token?input_token=%s",
|
||||||
|
c.config.APIVersion,
|
||||||
|
c.config.AccessToken)
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create token validation request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.config.AccessToken)
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to validate token: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read token validation response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
var errResp ErrorResponse
|
||||||
|
if err := json.Unmarshal(body, &errResp); err == nil {
|
||||||
|
return nil, fmt.Errorf("token validation failed: %s (code: %d)", errResp.Error.Message, errResp.Error.Code)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("token validation returned status %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var tokenResp TokenDebugResponse
|
||||||
|
if err := json.Unmarshal(body, &tokenResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse token validation response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !tokenResp.Data.IsValid {
|
||||||
|
return nil, fmt.Errorf("access token is invalid or expired")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &tokenResp.Data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getPhoneNumberDetails retrieves details about the phone number
|
||||||
|
func (c *Client) getPhoneNumberDetails(ctx context.Context) (*PhoneNumberDetails, error) {
|
||||||
|
url := fmt.Sprintf("https://graph.facebook.com/%s/%s",
|
||||||
|
c.config.APIVersion,
|
||||||
|
c.config.PhoneNumberID)
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create phone number details request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.config.AccessToken)
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get phone number details: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read phone number details response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
var errResp ErrorResponse
|
||||||
|
if err := json.Unmarshal(body, &errResp); err == nil {
|
||||||
|
return nil, fmt.Errorf("API error: %s (code: %d, subcode: %d)",
|
||||||
|
errResp.Error.Message, errResp.Error.Code, errResp.Error.ErrorSubcode)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var phoneDetails PhoneNumberDetails
|
||||||
|
if err := json.Unmarshal(body, &phoneDetails); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse phone number details: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &phoneDetails, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getBusinessAccountDetails retrieves details about the business account
|
||||||
|
func (c *Client) getBusinessAccountDetails(ctx context.Context) (*BusinessAccountDetails, error) {
|
||||||
|
url := fmt.Sprintf("https://graph.facebook.com/%s/%s",
|
||||||
|
c.config.APIVersion,
|
||||||
|
c.config.BusinessAccountID)
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create business account details request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.config.AccessToken)
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get business account details: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read business account details response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
var errResp ErrorResponse
|
||||||
|
if err := json.Unmarshal(body, &errResp); err == nil {
|
||||||
|
return nil, fmt.Errorf("API error: %s (code: %d)", errResp.Error.Message, errResp.Error.Code)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var businessDetails BusinessAccountDetails
|
||||||
|
if err := json.Unmarshal(body, &businessDetails); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse business account details: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &businessDetails, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkMissingScopes checks which required scopes are missing from the token
|
||||||
|
func (c *Client) checkMissingScopes(currentScopes []string, requiredScopes []string) []string {
|
||||||
|
scopeMap := make(map[string]bool)
|
||||||
|
for _, scope := range currentScopes {
|
||||||
|
scopeMap[scope] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
var missing []string
|
||||||
|
for _, required := range requiredScopes {
|
||||||
|
if !scopeMap[required] {
|
||||||
|
missing = append(missing, required)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return missing
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatExpiry formats the expiry timestamp for logging
|
||||||
|
func (c *Client) formatExpiry(expiresAt int64) string {
|
||||||
|
if expiresAt == 0 {
|
||||||
|
return "never"
|
||||||
|
}
|
||||||
|
expiryTime := time.Unix(expiresAt, 0)
|
||||||
|
return expiryTime.Format("2006-01-02 15:04:05 MST")
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.warky.dev/wdevs/whatshooked/pkg/events"
|
"git.warky.dev/wdevs/whatshooked/pkg/events"
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -204,6 +204,12 @@ func (s *Server) setupRoutes() *http.ServeMux {
|
|||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
h := s.wh.Handlers()
|
h := s.wh.Handlers()
|
||||||
|
|
||||||
|
// Landing page (no auth required)
|
||||||
|
mux.HandleFunc("/", h.ServeIndex)
|
||||||
|
|
||||||
|
// Static files (no auth required)
|
||||||
|
mux.HandleFunc("/static/", h.ServeStatic)
|
||||||
|
|
||||||
// Health check (no auth required)
|
// Health check (no auth required)
|
||||||
mux.HandleFunc("/health", h.Health)
|
mux.HandleFunc("/health", h.Health)
|
||||||
|
|
||||||
@@ -215,7 +221,10 @@ func (s *Server) setupRoutes() *http.ServeMux {
|
|||||||
// Account management (with auth)
|
// Account management (with auth)
|
||||||
mux.HandleFunc("/api/accounts", h.Auth(h.Accounts))
|
mux.HandleFunc("/api/accounts", h.Auth(h.Accounts))
|
||||||
mux.HandleFunc("/api/accounts/add", h.Auth(h.AddAccount))
|
mux.HandleFunc("/api/accounts/add", h.Auth(h.AddAccount))
|
||||||
|
mux.HandleFunc("/api/accounts/update", h.Auth(h.UpdateAccount))
|
||||||
mux.HandleFunc("/api/accounts/remove", h.Auth(h.RemoveAccount))
|
mux.HandleFunc("/api/accounts/remove", h.Auth(h.RemoveAccount))
|
||||||
|
mux.HandleFunc("/api/accounts/disable", h.Auth(h.DisableAccount))
|
||||||
|
mux.HandleFunc("/api/accounts/enable", h.Auth(h.EnableAccount))
|
||||||
|
|
||||||
// Send messages (with auth)
|
// Send messages (with auth)
|
||||||
mux.HandleFunc("/api/send", h.Auth(h.SendMessage))
|
mux.HandleFunc("/api/send", h.Auth(h.SendMessage))
|
||||||
@@ -232,11 +241,24 @@ 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",
|
||||||
|
"index", "/",
|
||||||
|
"static", "/static/*",
|
||||||
"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",
|
||||||
|
"webhooks", "/webhooks/whatsapp/*",
|
||||||
"qr", "/api/qr")
|
"qr", "/api/qr")
|
||||||
|
|
||||||
return mux
|
return mux
|
||||||
|
|||||||
@@ -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) {
|
||||||
@@ -112,6 +131,12 @@ func newWithConfig(cfg *config.Config, configPath string) (*WhatsHooked, error)
|
|||||||
// ConnectAll connects to all configured WhatsApp accounts
|
// ConnectAll connects to all configured WhatsApp accounts
|
||||||
func (wh *WhatsHooked) ConnectAll(ctx context.Context) error {
|
func (wh *WhatsHooked) ConnectAll(ctx context.Context) error {
|
||||||
for _, waCfg := range wh.config.WhatsApp {
|
for _, waCfg := range wh.config.WhatsApp {
|
||||||
|
// Skip disabled accounts
|
||||||
|
if waCfg.Disabled {
|
||||||
|
logging.Info("Skipping disabled account", "account_id", waCfg.ID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
if err := wh.whatsappMgr.Connect(ctx, waCfg); err != nil {
|
if err := wh.whatsappMgr.Connect(ctx, waCfg); err != nil {
|
||||||
logging.Error("Failed to connect to WhatsApp", "account_id", waCfg.ID, "error", err)
|
logging.Error("Failed to connect to WhatsApp", "account_id", waCfg.ID, "error", err)
|
||||||
// Continue connecting to other accounts even if one fails
|
// Continue connecting to other accounts even if one fails
|
||||||
|
|||||||
Reference in New Issue
Block a user