Skip to content

Commit

Permalink
feat(service): Add Web Push (#441)
Browse files Browse the repository at this point in the history
Co-authored-by: Kavii Suri <kavii@Kaviis-MacBook-Pro.local>
Co-authored-by: Niko Köser <koeserniko@gmail.com>
  • Loading branch information
3 people authored Jun 7, 2023
1 parent 3f4d10d commit 5e9ddeb
Show file tree
Hide file tree
Showing 8 changed files with 998 additions and 33 deletions.
1 change: 0 additions & 1 deletion .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ linters:
enable:
- 'asciicheck'
- 'bodyclose'
- 'depguard'
- 'dogsled'
- 'errcheck'
- 'errorlint'
Expand Down
65 changes: 33 additions & 32 deletions README.md

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ require (
)

require (
github.com/SherClockHolmes/webpush-go v1.2.0
github.com/appleboy/go-fcm v0.1.5
github.com/drswork/go-twitter v0.0.0-20221107160839-dea1b6ed53d7
github.com/go-lark/lark v1.7.4
Expand All @@ -38,6 +39,8 @@ require (
maunium.net/go/mautrix v0.15.2
)

require github.com/golang-jwt/jwt v3.2.2+incompatible // indirect

require (
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible
github.com/vartanbeno/go-reddit/v2 v2.0.1
Expand Down
5 changes: 5 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ github.com/Jeffail/gabs v1.4.0 h1://5fYRRTq1edjfIrQGvdkcd22pkYUrHZ5YC/H2GJVAo=
github.com/Jeffail/gabs v1.4.0/go.mod h1:6xMvQMK4k33lb7GUUpaAPh6nKMmemQeg5d4gn7/bOXc=
github.com/RocketChat/Rocket.Chat.Go.SDK v0.0.0-20221121042443-a3fd332d56d9 h1:vuu1KBsr6l7XU3CHsWESP/4B1SNd+VZkrgeFZsUXrsY=
github.com/RocketChat/Rocket.Chat.Go.SDK v0.0.0-20221121042443-a3fd332d56d9/go.mod h1:rjP7sIipbZcagro/6TCk6X0ZeFT2eyudH5+fve/cbBA=
github.com/SherClockHolmes/webpush-go v1.2.0 h1:sGv0/ZWCvb1HUH+izLqrb2i68HuqD/0Y+AmGQfyqKJA=
github.com/SherClockHolmes/webpush-go v1.2.0/go.mod h1:w6X47YApe/B9wUz2Wh8xukxlyupaxSSEbu6yKJcHN2w=
github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a h1:HbKu58rmZpUGpz5+4FfNmIU+FmZg2P3Xaj2v2bfNWmk=
github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc=
github.com/alicebob/miniredis/v2 v2.30.0 h1:uA3uhDbCxfO9+DI/DuGeAMr9qI+noVWwGPNTFuKID5M=
Expand Down Expand Up @@ -117,6 +119,8 @@ github.com/go-test/deep v1.0.4/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3a
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gofrs/uuid v4.2.0+incompatible h1:yyYWMnhkhrKwwr8gAOcOCYxOOscHgDS9yZgBrnJfGa0=
github.com/gofrs/uuid v4.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/golang-jwt/jwt/v5 v5.0.0-rc.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
Expand Down Expand Up @@ -309,6 +313,7 @@ github.com/yuin/gopher-lua v0.0.0-20220504180219-658193537a64/go.mod h1:GBR0iDaN
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI=
golang.org/x/crypto v0.0.0-20190131182504-b8fe1690c613/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
Expand Down
51 changes: 51 additions & 0 deletions service/webpush/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Webpush Notifications

[![go.dev reference](https://img.shields.io/badge/go.dev-reference-007d9c?logo=go&logoColor=white&style=flat)](https://pkg.go.dev/github.com/nikoksr/notify/service/webpush)


## Prerequisites

Generate VAPID Public and Private Keys for the notification service. This can be done using many tools, one of which is [`GenerateVAPIDKeys`](https://pkg.go.dev/github.com/SherClockHolmes/webpush-go#GenerateVAPIDKeys) from [webpush-go](https://github.com/SherClockHolmes/webpush-go/).

### Compatibility

This service is compatible with the [Web Push Protocol](https://tools.ietf.org/html/rfc8030) and [VAPID](https://tools.ietf.org/html/rfc8292).

For a list of compatible browsers, see [this](https://caniuse.com/push-api) for the Push API and [this](https://caniuse.com/notifications) for the Web Notifications.

## Usage
```go
package main

import (
"context"
"log"

"github.com/nikoksr/notify"
"github.com/nikoksr/notify/service/webpush"
)

const vapidPublicKey = "..." // Add a vapidPublicKey
const vapidPrivateKey = "..." // Add a vapidPrivateKey

func main() {
subscription := webpush.Subscription{
Endpoint: "https://your-endpoint",
Keys: {
Auth: "...",
P256dh: "...",
},
}

webpushSvc := webpush.New(vapidPublicKey, vapidPrivateKey)
webpushSvc.AddReceivers(subscription)

notifier := notify.NewWithServices(webpushSvc)

if err := notifier.Send(context.Background(), "TEST", "Message using golang notifier library"); err != nil {
log.Fatalf("notifier.Send() failed: %s", err.Error())
}

log.Println("Notification sent successfully")
}
```
41 changes: 41 additions & 0 deletions service/webpush/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/*
Package webpush provides a service for sending messages to viber.
Usage:
package main
import (
"context"
"log"
"github.com/nikoksr/notify"
"github.com/nikoksr/notify/service/webpush"
)
const vapidPublicKey = "..." // Add a vapidPublicKey
const vapidPrivateKey = "..." // Add a vapidPrivateKey
func main() {
subscription := webpush.Subscription{
Endpoint: "https://your-endpoint",
Keys: {
Auth: "...",
P256dh: "...",
},
}
webpushSvc := webpush.New(vapidPublicKey, vapidPrivateKey)
webpushSvc.AddReceivers(subscription)
notifier := notify.NewWithServices(webpushSvc)
if err := notifier.Send(context.Background(), "TEST", "Message using golang notifier library"); err != nil {
log.Fatalf("notifier.Send() failed: %s", err.Error())
}
log.Println("Notification sent successfully")
}
*/
package webpush
189 changes: 189 additions & 0 deletions service/webpush/webpush.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
package webpush

import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"

"github.com/SherClockHolmes/webpush-go"
"github.com/pkg/errors"
)

type (
// Urgency indicates the importance of the message. It's a type alias for webpush.Urgency.
Urgency = webpush.Urgency

// Options are optional settings for the sending of a message. It's a type alias for webpush.Options.
Options = webpush.Options

// Subscription is a JSON representation of a webpush subscription. It's a type alias for webpush.Subscription.
Subscription = webpush.Subscription

// messagePayload is the JSON payload that is sent to the webpush endpoint.
messagePayload struct {
Subject string `json:"subject"`
Message string `json:"message"`
Data map[string]interface{} `json:"data,omitempty"`
}

msgDataKey struct{}
msgOptionsKey struct{}
)

// optionsKey is used as a context.Context key to optionally add options to the messagePayload payload.
var optionsKey = msgOptionsKey{}

// dataKey is used as a context.Context key to optionally add data to the messagePayload payload.
var dataKey = msgDataKey{}

// These are exposed Urgency constants from the webpush package.
var (
// UrgencyVeryLow requires device state: on power and Wi-Fi
UrgencyVeryLow Urgency = webpush.UrgencyVeryLow

// UrgencyLow requires device state: on either power or Wi-Fi
UrgencyLow Urgency = webpush.UrgencyLow

// UrgencyNormal excludes device state: low battery
UrgencyNormal Urgency = webpush.UrgencyNormal

// UrgencyHigh admits device state: low battery
UrgencyHigh Urgency = webpush.UrgencyHigh
)

// Service encapsulates the webpush notification system along with the internal state
type Service struct {
subscriptions []webpush.Subscription
options webpush.Options
}

// New returns a new instance of the Service
func New(vapidPublicKey string, vapidPrivateKey string) *Service {
return &Service{
subscriptions: []webpush.Subscription{},
options: webpush.Options{
VAPIDPublicKey: vapidPublicKey,
VAPIDPrivateKey: vapidPrivateKey,
},
}
}

// AddReceivers adds one or more subscriptions to the Service.
func (s *Service) AddReceivers(subscriptions ...Subscription) {
s.subscriptions = append(s.subscriptions, subscriptions...)
}

// withOptions returns a new Options struct with the incoming options merged with the Service's options. The incoming
// options take precedence, except for the VAPID keys. Existing VAPID keys are only replaced if the incoming VAPID keys
// are not empty.
func (s *Service) withOptions(options Options) Options {
if options.VAPIDPublicKey == "" {
options.VAPIDPublicKey = s.options.VAPIDPublicKey
}
if options.VAPIDPrivateKey == "" {
options.VAPIDPrivateKey = s.options.VAPIDPrivateKey
}

return options
}

// WithOptions binds the options to the context so that they will be used by the Service.Send method automatically. Options
// are settings that allow you to customize the sending behavior of a message.
func WithOptions(ctx context.Context, options Options) context.Context {
return context.WithValue(ctx, optionsKey, options)
}

func optionsFromContext(ctx context.Context) Options {
if options, ok := ctx.Value(optionsKey).(Options); ok {
return options
}

return Options{}
}

// WithData binds the data to the context so that it will be used by the Service.Send method automatically. Data is a
// map[string]interface{} and acts as a metadata field that is sent along with the message payload.
func WithData(ctx context.Context, data map[string]interface{}) context.Context {
return context.WithValue(ctx, dataKey, data)
}

func dataFromContext(ctx context.Context) map[string]interface{} {
if data, ok := ctx.Value(dataKey).(map[string]interface{}); ok {
return data
}

return map[string]interface{}{}
}

// payloadFromContext returns a json encoded byte array of the messagePayload payload that is ready to be sent to the
// webpush endpoint. Internally, it uses the messagePayload and data from the context, and it combines it with the
// subject and message arguments into a single messagePayload.
func payloadFromContext(ctx context.Context, subject, message string) ([]byte, error) {
payload := messagePayload{
Subject: subject,
Message: message,
}

payload.Data = dataFromContext(ctx) // Load optional data

payloadBytes, err := json.Marshal(payload)
if err != nil {
return nil, errors.Wrap(err, "failed to serialize messagePayload")
}

return payloadBytes, nil
}

// send is a wrapper that makes it primarily easier to defer the closing of the response body.
func (s *Service) send(ctx context.Context, message []byte, subscription *Subscription, options *Options) error {
res, err := webpush.SendNotificationWithContext(ctx, message, subscription, options)
if err != nil {
return errors.Wrapf(err, "failed to send messagePayload to webpush subscription %s", subscription.Endpoint)
}
defer res.Body.Close()

if res.StatusCode == http.StatusOK || res.StatusCode == http.StatusCreated {
return nil // Everything is fine
}

// Make sure to produce a helpful error message

baseErr := errors.Errorf(
"failed to send message to webpush subscription %s: unexpected status code %d",
subscription.Endpoint, res.StatusCode,
)

body, err := io.ReadAll(res.Body)
if err != nil {
baseErr = errors.Wrap(errors.Wrap(err, "failed to read response body"), baseErr.Error())
} else {
baseErr = fmt.Errorf("%w: %s", baseErr, body)
}

return baseErr
}

// Send sends a message to all the webpush subscriptions that have been added to the Service. The subject and message
// arguments are the subject and message of the messagePayload payload. The context can be used to optionally add
// options and data to the messagePayload payload. See the WithOptions and WithData functions.
func (s *Service) Send(ctx context.Context, subject, message string) error {
// Get the options from the context and merge them with the service's initial options
options := optionsFromContext(ctx)
options = s.withOptions(options)

payload, err := payloadFromContext(ctx, subject, message)
if err != nil {
return err
}

for _, subscription := range s.subscriptions {
subscription := subscription // Capture the subscription in the closure
if err := s.send(ctx, payload, &subscription, &options); err != nil {
return err
}
}

return nil
}
Loading

0 comments on commit 5e9ddeb

Please sign in to comment.