package services import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "strings" "sync" "time" "github.com/avast/retry-go/v5" "github.com/pkg/errors" "github.com/gofiber/fiber/v3" "github.com/NdoleStudio/httpsms/pkg/events" "github.com/NdoleStudio/httpsms/pkg/entities" "github.com/NdoleStudio/httpsms/pkg/repositories" "github.com/NdoleStudio/httpsms/pkg/telemetry" cloudevents "github.com/cloudevents/sdk-go/v2" "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" "github.com/lib/pq" "github.com/palantir/stacktrace" ) // WebhookService is responsible for handling webhooks type WebhookService struct { service logger telemetry.Logger tracer telemetry.Tracer client *http.Client repository repositories.WebhookRepository dispatcher *EventDispatcher } // NewWebhookService creates a new WebhookService func NewWebhookService( logger telemetry.Logger, tracer telemetry.Tracer, client *http.Client, repository repositories.WebhookRepository, dispatcher *EventDispatcher, ) (s *WebhookService) { return &WebhookService{ logger: logger.WithService(fmt.Sprintf("%T", s)), tracer: tracer, client: client, dispatcher: dispatcher, repository: repository, } } // DeleteAllForUser deletes all entities.Webhook for an entities.UserID. func (service *WebhookService) DeleteAllForUser(ctx context.Context, userID entities.UserID) error { ctx, span, ctxLogger := service.tracer.StartWithLogger(ctx, service.logger) defer span.End() if err := service.repository.DeleteAllForUser(ctx, userID); err != nil { msg := fmt.Sprintf("could not delete all [entities.Webhook] for user with ID [%s]", userID) return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) } ctxLogger.Info(fmt.Sprintf("deleted all [entities.Webhook] for user with ID [%s]", userID)) return nil } // Index fetches the entities.Webhook for an entities.UserID func (service *WebhookService) Index(ctx context.Context, userID entities.UserID, params repositories.IndexParams) ([]*entities.Webhook, error) { ctx, span := service.tracer.Start(ctx) defer span.End() ctxLogger := service.tracer.CtxLogger(service.logger, span) webhooks, err := service.repository.Index(ctx, userID, params) if err != nil { msg := fmt.Sprintf("could not fetch webhooks with params [%+#v]", params) return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) } ctxLogger.Info(fmt.Sprintf("fetched [%d] webhooks with prams [%+#v]", len(webhooks), params)) return webhooks, nil } // Delete an entities.Webhook func (service *WebhookService) Delete(ctx context.Context, userID entities.UserID, webhookID uuid.UUID) error { ctx, span := service.tracer.Start(ctx) defer span.End() ctxLogger := service.tracer.CtxLogger(service.logger, span) if _, err := service.repository.Load(ctx, userID, webhookID); err != nil { msg := fmt.Sprintf("cannot load webhook with userID [%s] and phoneID [%s]", userID, webhookID) return service.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, stacktrace.GetCode(err), msg)) } if err := service.repository.Delete(ctx, userID, webhookID); err != nil { msg := fmt.Sprintf("cannot delete webhook with id [%s] and user id [%s]", webhookID, userID) return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) } ctxLogger.Info(fmt.Sprintf("deleted webhook with id [%s] and user id [%s]", webhookID, userID)) return nil } // WebhookStoreParams are parameters for creating a new entities.Webhook type WebhookStoreParams struct { UserID entities.UserID SigningKey string URL string PhoneNumbers pq.StringArray Events pq.StringArray } // Store a new entities.Webhook func (service *WebhookService) Store(ctx context.Context, params *WebhookStoreParams) (*entities.Webhook, error) { ctx, span := service.tracer.Start(ctx) defer span.End() ctxLogger := service.tracer.CtxLogger(service.logger, span) webhook := &entities.Webhook{ ID: uuid.New(), UserID: params.UserID, URL: params.URL, PhoneNumbers: params.PhoneNumbers, SigningKey: params.SigningKey, Events: params.Events, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), } if err := service.repository.Save(ctx, webhook); err != nil { msg := fmt.Sprintf("cannot save webhook with id [%s]", webhook.ID) return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) } ctxLogger.Info(fmt.Sprintf("webhook saved with id [%s] for user [%s] in the [%T]", webhook.ID, webhook.UserID, service.repository)) return webhook, nil } // WebhookUpdateParams are parameters for updating an entities.Webhook type WebhookUpdateParams struct { UserID entities.UserID SigningKey string URL string Events pq.StringArray PhoneNumbers pq.StringArray WebhookID uuid.UUID } // Update an entities.Webhook func (service *WebhookService) Update(ctx context.Context, params *WebhookUpdateParams) (*entities.Webhook, error) { ctx, span, ctxLogger := service.tracer.StartWithLogger(ctx, service.logger) defer span.End() webhook, err := service.repository.Load(ctx, params.UserID, params.WebhookID) if err != nil { msg := fmt.Sprintf("cannot load webhook with userID [%s] and phoneID [%s]", params.UserID, params.WebhookID) return nil, service.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, stacktrace.GetCode(err), msg)) } webhook.URL = params.URL webhook.SigningKey = params.SigningKey webhook.Events = params.Events webhook.PhoneNumbers = params.PhoneNumbers if err = service.repository.Save(ctx, webhook); err != nil { msg := fmt.Sprintf("cannot save webhook with id [%s] after update", webhook.ID) return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) } ctxLogger.Info(fmt.Sprintf("webhook updated with id [%s] in the [%T]", webhook.ID, service.repository)) return webhook, nil } // Send an event to a subscribed webhook func (service *WebhookService) Send(ctx context.Context, userID entities.UserID, event cloudevents.Event, phoneNumber string) error { ctx, span, ctxLogger := service.tracer.StartWithLogger(ctx, service.logger) defer span.End() webhooks, err := service.repository.LoadByEvent(ctx, userID, event.Type(), phoneNumber) if err != nil { msg := fmt.Sprintf("cannot load webhooks for userID [%s] and event [%s]", userID, event.Type()) return service.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, stacktrace.GetCode(err), msg)) } if len(webhooks) == 0 { ctxLogger.Info(fmt.Sprintf("user [%s] has no webhook subscription to event [%s]", userID, event.Type())) return nil } var wg sync.WaitGroup for _, webhook := range webhooks { wg.Add(1) go func(webhook *entities.Webhook) { defer wg.Done() service.sendNotification(ctx, event, phoneNumber, webhook) }(webhook) } wg.Wait() return nil } func (service *WebhookService) sendNotification(ctx context.Context, event cloudevents.Event, owner string, webhook *entities.Webhook) { ctx, span, ctxLogger := service.tracer.StartWithLogger(ctx, service.logger) defer span.End() attempts := 0 err := retry.New(retry.Attempts(2)).Do(func() error { attempts++ requestCtx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() request, err := service.createRequest(requestCtx, event, webhook) if err != nil { msg := fmt.Sprintf("cannot create [%s] event to webhook [%s] for user [%s] after [%d] attempts", event.Type(), webhook.URL, webhook.UserID, attempts) return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) } response, err := service.client.Do(request) if err != nil { ctxLogger.Warn(stacktrace.Propagate(err, fmt.Sprintf("cannot send [%s] event to webhook [%s] for user [%s] after [%d] attempts", event.Type(), webhook.URL, webhook.UserID, attempts))) if attempts == 1 { return err } service.handleWebhookSendFailed(ctx, event, webhook, owner, err, response) return nil } defer func() { err = response.Body.Close() if err != nil { ctxLogger.Error(stacktrace.Propagate(err, fmt.Sprintf("cannot close response body for [%s] event with ID [%s] after [%d] attempts", event.Type(), event.ID(), attempts))) } }() if response.StatusCode >= 400 { ctxLogger.Info(fmt.Sprintf("cannot send [%s] event to webhook [%s] for user [%s] with response code [%d] after [%d] attempts", event.Type(), webhook.URL, webhook.UserID, response.StatusCode, attempts)) if attempts == 1 { return stacktrace.NewError(http.StatusText(response.StatusCode)) } service.handleWebhookSendFailed(ctx, event, webhook, owner, stacktrace.NewError(http.StatusText(response.StatusCode)), response) return nil } ctxLogger.Info(fmt.Sprintf("sent webhook to url [%s] for event [%s] with ID [%s] and response code [%d]", webhook.URL, event.Type(), event.ID(), response.StatusCode)) return nil }) if err != nil { msg := fmt.Sprintf("cannot handle [%s] event to webhook [%s] for user [%s] after [%d] attempts", event.Type(), webhook.URL, webhook.UserID, attempts) ctxLogger.Error(service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))) } } func (service *WebhookService) createRequest(ctx context.Context, event cloudevents.Event, webhook *entities.Webhook) (*http.Request, error) { ctx, span, ctxLogger := service.tracer.StartWithLogger(ctx, service.logger) defer span.End() payload, err := json.Marshal(service.getPayload(ctxLogger, event, webhook)) if err != nil { msg := fmt.Sprintf("cannot marshal payload for user [%s] and webhook [%s] for event [%s]", webhook.UserID, webhook.ID, event.ID()) return nil, stacktrace.Propagate(err, msg) } request, err := http.NewRequestWithContext(ctx, http.MethodPost, webhook.URL, bytes.NewReader(payload)) if err != nil { msg := fmt.Sprintf("cannot create request for user [%s] and webhook [%s] for event [%s]", webhook.UserID, webhook.ID, event.ID()) return nil, stacktrace.Propagate(err, msg) } request.Header.Add("X-Event-Type", event.Type()) request.Header.Set("Content-Type", "application/json") if strings.TrimSpace(webhook.SigningKey) != "" { token, err := service.getAuthToken(webhook) if err != nil { msg := fmt.Sprintf("cannot generate auth token for user [%s] and webhook [%s]", webhook.UserID, webhook.ID) return nil, stacktrace.Propagate(err, msg) } request.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token)) } return request, nil } func (service *WebhookService) getPayload(ctxLogger telemetry.Logger, event cloudevents.Event, webhook *entities.Webhook) any { if event.Type() != events.EventTypeMessagePhoneReceived { return event } if !strings.HasPrefix(webhook.URL, "https://discord.com/api/webhooks/") { return event } payload := new(events.MessagePhoneReceivedPayload) err := event.DataAs(payload) if err != nil { ctxLogger.Error(stacktrace.Propagate(err, fmt.Sprintf("cannot unmarshal event [%s] with ID [%s] into [%T]", event.Type(), event.ID(), payload))) return event } return map[string]any{ "avatar_url": "https://httpsms.com/avatar.png", "username": "httpsms.com", "content": "✉ new message received", "embeds": []fiber.Map{ { "fields": []fiber.Map{ { "name": "From:", "value": service.getFormattedNumber(ctxLogger, payload.Contact), "inline": true, }, { "name": "To:", "value": service.getFormattedNumber(ctxLogger, payload.Owner), "inline": true, }, { "name": "Content:", "value": payload.Content, }, { "name": "MessageID:", "value": payload.MessageID, }, }, }, }, } } func (service *WebhookService) getAuthToken(webhook *entities.Webhook) (string, error) { token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{ Audience: []string{webhook.URL}, ExpiresAt: jwt.NewNumericDate(time.Now().UTC().Add(10 * time.Minute)), IssuedAt: jwt.NewNumericDate(time.Now().UTC()), Issuer: "api.httpsms.com", NotBefore: jwt.NewNumericDate(time.Now().UTC().Add(-10 * time.Minute)), Subject: string(webhook.UserID), }) return token.SignedString([]byte(webhook.SigningKey)) } func (service *WebhookService) handleWebhookSendFailed(ctx context.Context, event cloudevents.Event, webhook *entities.Webhook, owner string, err error, response *http.Response) { ctx, span, ctxLogger := service.tracer.StartWithLogger(ctx, service.logger) defer span.End() payload := &events.WebhookSendFailedPayload{ WebhookID: webhook.ID, WebhookURL: webhook.URL, UserID: webhook.UserID, EventID: event.ID(), Owner: owner, EventType: event.Type(), EventPayload: string(event.Data()), HTTPResponseStatusCode: nil, ErrorMessage: err.Error(), } if errors.Is(err, context.DeadlineExceeded) { payload.ErrorMessage = "TIMEOUT after 10 seconds" } if response != nil { payload.HTTPResponseStatusCode = &response.StatusCode payload.ErrorMessage = http.StatusText(response.StatusCode) body, err := io.ReadAll(response.Body) if err == nil && len(body) > 0 { payload.ErrorMessage = string(body) } } event, err = service.createEvent(events.EventTypeWebhookSendFailed, event.Source(), payload) if err != nil { msg := fmt.Sprintf("cannot create event [%s] for user with id [%s]", events.EventTypeWebhookSendFailed, payload.UserID) ctxLogger.Error(service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))) return } if err = service.dispatcher.Dispatch(ctx, event); err != nil { msg := fmt.Sprintf("cannot dispatch event [%s] for user with id [%s]", event.Type(), payload.UserID) ctxLogger.Error(service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))) return } ctxLogger.Info(fmt.Sprintf("dispatched [%s] event with ID [%s] for user with id [%s]", event.Type(), event.ID(), payload.UserID)) }