package hooks import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "sync" "time" "git.warky.dev/wdevs/whatshooked/pkg/config" "git.warky.dev/wdevs/whatshooked/pkg/events" "git.warky.dev/wdevs/whatshooked/pkg/logging" ) // MediaInfo represents media attachment information type MediaInfo struct { Type string `json:"type"` MimeType string `json:"mime_type,omitempty"` Filename string `json:"filename,omitempty"` URL string `json:"url,omitempty"` Base64 string `json:"base64,omitempty"` } // MessagePayload represents a message sent to webhooks type MessagePayload struct { AccountID string `json:"account_id"` MessageID string `json:"message_id"` From string `json:"from"` To string `json:"to"` Text string `json:"text"` Timestamp time.Time `json:"timestamp"` IsGroup bool `json:"is_group"` GroupName string `json:"group_name,omitempty"` SenderName string `json:"sender_name,omitempty"` MessageType string `json:"message_type"` Media *MediaInfo `json:"media,omitempty"` } // HookResponse represents a response from a webhook type HookResponse struct { SendMessage bool `json:"send_message"` To string `json:"to"` Text string `json:"text"` AccountID string `json:"account_id,omitempty"` } // Manager manages webhooks type Manager struct { hooks map[string]config.Hook mu sync.RWMutex client *http.Client eventBus *events.EventBus } // NewManager creates a new hook manager func NewManager(eventBus *events.EventBus) *Manager { return &Manager{ hooks: make(map[string]config.Hook), eventBus: eventBus, client: &http.Client{ Timeout: 30 * time.Second, }, } } // Start begins listening for events func (m *Manager) Start() { // Get all possible event types allEventTypes := []events.EventType{ events.EventWhatsAppConnected, events.EventWhatsAppDisconnected, events.EventWhatsAppPairSuccess, events.EventWhatsAppPairFailed, events.EventWhatsAppQRCode, events.EventWhatsAppQRTimeout, events.EventWhatsAppQRError, events.EventWhatsAppPairEvent, events.EventMessageReceived, events.EventMessageSent, events.EventMessageFailed, events.EventMessageDelivered, events.EventMessageRead, } // Subscribe to all event types with a generic handler 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 } // Check if this hook is subscribed to this event type 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 } } } 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) } } // triggerHooksForEvent sends event data to specific hooks func (m *Manager) triggerHooksForEvent(event events.Event, hooks []config.Hook) { ctx := event.Context if ctx == nil { ctx = context.Background() } // Create payload based on event type var payload interface{} // For message events, create MessagePayload if event.Type == events.EventMessageReceived || event.Type == events.EventMessageSent { messageType := getStringFromEvent(event.Data, "message_type") msgPayload := MessagePayload{ AccountID: getStringFromEvent(event.Data, "account_id"), MessageID: getStringFromEvent(event.Data, "message_id"), From: getStringFromEvent(event.Data, "from"), To: getStringFromEvent(event.Data, "to"), Text: getStringFromEvent(event.Data, "text"), Timestamp: getTimeFromEvent(event.Data, "timestamp"), IsGroup: getBoolFromEvent(event.Data, "is_group"), GroupName: getStringFromEvent(event.Data, "group_name"), SenderName: getStringFromEvent(event.Data, "sender_name"), MessageType: messageType, } // Add media info if message has media content if messageType != "" && messageType != "text" { msgPayload.Media = &MediaInfo{ Type: messageType, MimeType: getStringFromEvent(event.Data, "mime_type"), Filename: getStringFromEvent(event.Data, "filename"), URL: getStringFromEvent(event.Data, "media_url"), Base64: getStringFromEvent(event.Data, "media_base64"), } } payload = msgPayload } else { // For other events, create generic payload with event type and data payload = map[string]interface{}{ "event_type": string(event.Type), "timestamp": event.Timestamp, "data": event.Data, } } // Send to each hook with the event type var wg sync.WaitGroup for _, hook := range hooks { wg.Add(1) go func(h config.Hook, et events.EventType) { defer wg.Done() _ = m.sendToHook(ctx, h, payload, et) }(hook, event.Type) } wg.Wait() } // Helper functions to extract data from event map func getStringFromEvent(data map[string]interface{}, key string) string { if val, ok := data[key].(string); ok { return val } return "" } func getTimeFromEvent(data map[string]interface{}, key string) time.Time { if val, ok := data[key].(time.Time); ok { return val } return time.Time{} } func getBoolFromEvent(data map[string]interface{}, key string) bool { if val, ok := data[key].(bool); ok { return val } return false } // LoadHooks loads hooks from configuration func (m *Manager) LoadHooks(hooks []config.Hook) { m.mu.Lock() defer m.mu.Unlock() for _, hook := range hooks { m.hooks[hook.ID] = hook } logging.Info("Hooks loaded", "count", len(hooks)) } // AddHook adds a new hook func (m *Manager) AddHook(hook config.Hook) { m.mu.Lock() defer m.mu.Unlock() m.hooks[hook.ID] = hook logging.Info("Hook added", "id", hook.ID, "name", hook.Name) } // RemoveHook removes a hook func (m *Manager) RemoveHook(id string) error { m.mu.Lock() defer m.mu.Unlock() if _, exists := m.hooks[id]; !exists { return fmt.Errorf("hook %s not found", id) } delete(m.hooks, id) logging.Info("Hook removed", "id", id) return nil } // GetHook returns a hook by ID func (m *Manager) GetHook(id string) (config.Hook, bool) { m.mu.RLock() defer m.mu.RUnlock() hook, exists := m.hooks[id] return hook, exists } // ListHooks returns all hooks func (m *Manager) ListHooks() []config.Hook { m.mu.RLock() defer m.mu.RUnlock() hooks := make([]config.Hook, 0, len(m.hooks)) for _, hook := range m.hooks { hooks = append(hooks, hook) } return hooks } // 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 { // 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(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(eventCtx, hook.ID, hook.Name, err)) return nil } // Build URL with query parameters 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(eventCtx, hook.ID, hook.Name, err)) return nil } // Extract account_id from payload var accountID string switch p := payload.(type) { case MessagePayload: accountID = p.AccountID case map[string]interface{}: if data, ok := p["data"].(map[string]interface{}); ok { if aid, ok := data["account_id"].(string); ok { accountID = aid } } } // Add query parameters query := parsedURL.Query() if eventType != "" { query.Set("event", string(eventType)) } if accountID != "" { query.Set("account_id", accountID) } parsedURL.RawQuery = query.Encode() 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(eventCtx, hook.ID, hook.Name, err)) return nil } req.Header.Set("Content-Type", "application/json") for key, value := range hook.Headers { req.Header.Set(key, value) } logging.Debug("Sending to hook", "hook_id", hook.ID, "url", hook.URL) 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(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(eventCtx, hook.ID, hook.Name, fmt.Errorf("status code %d", resp.StatusCode))) return nil } // Try to parse response 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(eventCtx, hook.ID, hook.Name, err)) return nil } if len(body) == 0 { 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(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(eventCtx, hook.ID, hook.Name, resp.StatusCode, hookResp)) return &hookResp }