feat(whatsapp): Enhance webhook message handling
Some checks failed
CI / Test (1.22) (push) Failing after -24m5s
CI / Test (1.23) (push) Failing after -23m59s
CI / Build (push) Successful in -24m25s
CI / Lint (push) Failing after -24m11s

* Add support for new message types: audio, sticker, location, contacts, interactive, button, reaction, order, system, and unknown.
* Implement logging for various webhook events for better visibility.
* Update WebhookMessage struct to include new fields for enhanced message processing.
This commit is contained in:
Hein
2026-01-30 11:30:10 +02:00
parent 147dac9b60
commit 3901bbb668
3 changed files with 371 additions and 49 deletions

View File

@@ -90,20 +90,26 @@ func (m *Manager) Start() {
for _, eventType := range allEventTypes {
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
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
m.mu.RLock()
relevantHooks := make([]config.Hook, 0)
for _, hook := range m.hooks {
if !hook.Active {
logging.Debug("Skipping inactive hook", "hook_id", hook.ID)
continue
}
// If hook has no events specified, subscribe to all events
if len(hook.Events) == 0 {
logging.Debug("Hook subscribes to all events", "hook_id", hook.ID)
relevantHooks = append(relevantHooks, hook)
continue
}
@@ -112,6 +118,7 @@ func (m *Manager) handleEvent(event events.Event) {
eventTypeStr := string(event.Type)
for _, subscribedEvent := range hook.Events {
if subscribedEvent == eventTypeStr {
logging.Debug("Hook matches event", "hook_id", hook.ID, "event_type", eventTypeStr)
relevantHooks = append(relevantHooks, hook)
break
}
@@ -119,6 +126,8 @@ func (m *Manager) handleEvent(event events.Event) {
}
m.mu.RUnlock()
logging.Debug("Found relevant hooks for event", "event_type", event.Type, "hook_count", len(relevantHooks))
// Trigger each relevant hook
if len(relevantHooks) > 0 {
m.triggerHooksForEvent(event, relevantHooks)
@@ -265,17 +274,24 @@ func (m *Manager) ListHooks() []config.Hook {
// sendToHook sends any payload to a specific hook with explicit event type
func (m *Manager) sendToHook(ctx context.Context, hook config.Hook, payload interface{}, eventType events.EventType) *HookResponse {
if ctx == nil {
ctx = context.Background()
// Create a new context detached from the incoming context to prevent cancellation
// 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
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)
if err != nil {
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
}
@@ -283,7 +299,7 @@ func (m *Manager) sendToHook(ctx context.Context, hook config.Hook, payload inte
parsedURL, err := url.Parse(hook.URL)
if err != nil {
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
}
@@ -311,10 +327,10 @@ func (m *Manager) sendToHook(ctx context.Context, hook config.Hook, payload inte
}
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 {
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
}
@@ -328,14 +344,14 @@ func (m *Manager) sendToHook(ctx context.Context, hook config.Hook, payload inte
resp, err := m.client.Do(req)
if err != nil {
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
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
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
}
@@ -343,23 +359,23 @@ func (m *Manager) sendToHook(ctx context.Context, hook config.Hook, payload inte
body, err := io.ReadAll(resp.Body)
if err != nil {
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
}
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
}
var hookResp HookResponse
if err := json.Unmarshal(body, &hookResp); err != nil {
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
}
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
}

View File

@@ -12,6 +12,7 @@ import (
"os"
"path/filepath"
"strconv"
"strings"
"time"
"git.warky.dev/wdevs/whatshooked/pkg/events"
@@ -44,14 +45,53 @@ func (c *Client) HandleWebhook(r *http.Request) error {
func (c *Client) processChange(change WebhookChange) {
ctx := context.Background()
// Process messages
for _, msg := range change.Value.Messages {
c.processMessage(ctx, msg, change.Value.Contacts)
}
// Handle different field types
switch change.Field {
case "messages":
// Process messages
for _, msg := range change.Value.Messages {
c.processMessage(ctx, msg, change.Value.Contacts)
}
// Process statuses
for _, status := range change.Value.Statuses {
c.processStatus(ctx, status)
// Process statuses
for _, status := range change.Value.Statuses {
c.processStatus(ctx, status)
}
case "message_template_status_update":
// Log template status updates for visibility
logging.Info("Message template status update received",
"account_id", c.id,
"phone_number_id", change.Value.Metadata.PhoneNumberID)
case "account_update":
// Log account updates
logging.Info("Account update received",
"account_id", c.id,
"phone_number_id", change.Value.Metadata.PhoneNumberID)
case "phone_number_quality_update":
// Log quality updates
logging.Info("Phone number quality update received",
"account_id", c.id,
"phone_number_id", change.Value.Metadata.PhoneNumberID)
case "phone_number_name_update":
// Log name updates
logging.Info("Phone number name update received",
"account_id", c.id,
"phone_number_id", change.Value.Metadata.PhoneNumberID)
case "account_alerts":
// Log account alerts
logging.Warn("Account alert received",
"account_id", c.id,
"phone_number_id", change.Value.Metadata.PhoneNumberID)
default:
logging.Debug("Unknown webhook field type",
"account_id", c.id,
"field", change.Field)
}
}
@@ -130,12 +170,114 @@ func (c *Client) processMessage(ctx context.Context, msg WebhookMessage, contact
}
}
case "audio":
if msg.Audio != nil {
messageType = "audio"
mimeType = msg.Audio.MimeType
// Download and process media
data, _, err := c.downloadMedia(ctx, msg.Audio.ID)
if err != nil {
logging.Error("Failed to download audio", "account_id", c.id, "media_id", msg.Audio.ID, "error", err)
} else {
filename, mediaURL = c.processMediaData(msg.ID, data, mimeType, &mediaBase64)
}
}
case "sticker":
if msg.Sticker != nil {
messageType = "sticker"
mimeType = msg.Sticker.MimeType
// Download and process media
data, _, err := c.downloadMedia(ctx, msg.Sticker.ID)
if err != nil {
logging.Error("Failed to download sticker", "account_id", c.id, "media_id", msg.Sticker.ID, "error", err)
} else {
filename, mediaURL = c.processMediaData(msg.ID, data, mimeType, &mediaBase64)
}
}
case "location":
if msg.Location != nil {
messageType = "location"
// Format location as text
text = fmt.Sprintf("Location: %s (%s) - %.6f, %.6f",
msg.Location.Name, msg.Location.Address,
msg.Location.Latitude, msg.Location.Longitude)
}
case "contacts":
if len(msg.Contacts) > 0 {
messageType = "contacts"
// Format contacts as text
var contactNames []string
for _, contact := range msg.Contacts {
contactNames = append(contactNames, contact.Name.FormattedName)
}
text = fmt.Sprintf("Shared %d contact(s): %s", len(msg.Contacts), strings.Join(contactNames, ", "))
}
case "interactive":
if msg.Interactive != nil {
messageType = "interactive"
switch msg.Interactive.Type {
case "button_reply":
if msg.Interactive.ButtonReply != nil {
text = msg.Interactive.ButtonReply.Title
}
case "list_reply":
if msg.Interactive.ListReply != nil {
text = msg.Interactive.ListReply.Title
}
case "nfm_reply":
if msg.Interactive.NfmReply != nil {
text = msg.Interactive.NfmReply.Body
}
}
}
case "button":
if msg.Button != nil {
messageType = "button"
text = msg.Button.Text
}
case "reaction":
if msg.Reaction != nil {
messageType = "reaction"
text = msg.Reaction.Emoji
}
case "order":
if msg.Order != nil {
messageType = "order"
text = fmt.Sprintf("Order with %d item(s): %s", len(msg.Order.ProductItems), msg.Order.Text)
}
case "system":
if msg.System != nil {
messageType = "system"
text = msg.System.Body
}
case "unknown":
messageType = "unknown"
logging.Warn("Received unknown message type", "account_id", c.id, "message_id", msg.ID)
return
default:
logging.Warn("Unsupported message type", "account_id", c.id, "type", msg.Type)
return
}
// Publish message received event
logging.Debug("Publishing message received event",
"account_id", c.id,
"message_id", msg.ID,
"from", msg.From,
"type", messageType)
c.eventBus.Publish(events.MessageReceivedEvent(
ctx,
c.id,
@@ -276,7 +418,10 @@ func getExtensionFromMimeType(mimeType string) string {
"text/plain": ".txt",
"application/json": ".json",
"audio/mpeg": ".mp3",
"audio/mp4": ".m4a",
"audio/ogg": ".ogg",
"audio/amr": ".amr",
"audio/opus": ".opus",
}
if ext, ok := extensions[mimeType]; ok {

View File

@@ -116,15 +116,26 @@ type WebhookProfile struct {
// WebhookMessage represents a message in the webhook
type WebhookMessage struct {
From string `json:"from"` // Sender phone number
ID string `json:"id"` // Message ID
Timestamp string `json:"timestamp"` // Unix timestamp as string
Type string `json:"type"` // "text", "image", "video", "document", etc.
Text *WebhookText `json:"text,omitempty"`
Image *WebhookMediaMessage `json:"image,omitempty"`
Video *WebhookMediaMessage `json:"video,omitempty"`
Document *WebhookDocumentMessage `json:"document,omitempty"`
Context *WebhookContext `json:"context,omitempty"` // Reply context
From string `json:"from"` // Sender phone number
ID string `json:"id"` // Message ID
Timestamp string `json:"timestamp"` // Unix timestamp as string
Type string `json:"type"` // "text", "image", "video", "document", "audio", "sticker", "location", "contacts", "interactive", "button", "order", "system", "unknown", "reaction"
Text *WebhookText `json:"text,omitempty"`
Image *WebhookMediaMessage `json:"image,omitempty"`
Video *WebhookMediaMessage `json:"video,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
Identity *WebhookIdentity `json:"identity,omitempty"`
Referral *WebhookReferral `json:"referral,omitempty"`
}
// WebhookText represents a text message
@@ -156,6 +167,156 @@ type WebhookContext struct {
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
type WebhookStatus struct {
ID string `json:"id"` // Message ID
@@ -199,26 +360,26 @@ type TokenDebugResponse struct {
// 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"`
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"`
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
@@ -228,8 +389,8 @@ type ThroughputInfo struct {
// 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"`
ID string `json:"id"`
Name string `json:"name"`
TimezoneID string `json:"timezone_id"`
MessageTemplateNamespace string `json:"message_template_namespace,omitempty"`
}