diff --git a/pkg/hooks/manager.go b/pkg/hooks/manager.go index 4d9a579..5d9b3dd 100644 --- a/pkg/hooks/manager.go +++ b/pkg/hooks/manager.go @@ -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 } diff --git a/pkg/whatsapp/businessapi/events.go b/pkg/whatsapp/businessapi/events.go index 50138ba..fb89993 100644 --- a/pkg/whatsapp/businessapi/events.go +++ b/pkg/whatsapp/businessapi/events.go @@ -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 { diff --git a/pkg/whatsapp/businessapi/types.go b/pkg/whatsapp/businessapi/types.go index 4afea73..a1be0b9 100644 --- a/pkg/whatsapp/businessapi/types.go +++ b/pkg/whatsapp/businessapi/types.go @@ -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"` }