Outbound live stream notifications (#1663)

* First pass at browser, discord, twilio notifications

* Commit updated Javascript packages

* Remove twilio notification support

* Email notifications/smtp support

* Fix Firefox notification support, remove chrome checks

* WIP more email work

* Add support for twitter notifications

* Add stream title to discord and twitter notifications

* Update notification registration modal

* Fix hide/show email section

* Commit updated API documentation

* Commit updated Javascript packages

* Fix post-rebase missing var

* Remove unused var

* Handle unsubscribe errors for browser push

* Standardize email config prop names

* Allow overriding go live email template

* Some notifications cleanup

* Commit updated Javascript packages

* Remove email/smtp/mailjet support

* Remove more references to email notifications

Co-authored-by: Owncast <owncast@owncast.online>
This commit is contained in:
Gabe Kangas
2022-03-18 13:33:23 -07:00
committed by GitHub
parent 4e415f7257
commit 4a17f30da8
39 changed files with 2209 additions and 3313 deletions

View File

@@ -0,0 +1,83 @@
package browser
import (
"encoding/json"
"github.com/SherClockHolmes/webpush-go"
"github.com/owncast/owncast/core/data"
"github.com/pkg/errors"
)
// Browser is an instance of the Browser service.
type Browser struct {
datastore *data.Datastore
privateKey string
publicKey string
}
// New will create a new instance of the Browser service.
func New(datastore *data.Datastore, publicKey, privateKey string) (*Browser, error) {
return &Browser{
datastore: datastore,
privateKey: privateKey,
publicKey: publicKey,
}, nil
}
// GenerateBrowserPushKeys will create the VAPID keys required for web push notifications.
func GenerateBrowserPushKeys() (string, string, error) {
privateKey, publicKey, err := webpush.GenerateVAPIDKeys()
if err != nil {
return "", "", errors.Wrap(err, "error generating web push keys")
}
return privateKey, publicKey, nil
}
// Send will send a browser push notification to the given subscription.
func (b *Browser) Send(
subscription string,
title string,
body string,
) (bool, error) {
type message struct {
Title string `json:"title"`
Body string `json:"body"`
Icon string `json:"icon"`
}
m := message{
Title: title,
Body: body,
Icon: "/logo/external",
}
d, err := json.Marshal(m)
if err != nil {
return false, errors.Wrap(err, "error marshalling web push message")
}
// Decode subscription
s := &webpush.Subscription{}
if err := json.Unmarshal([]byte(subscription), s); err != nil {
return false, errors.Wrap(err, "error decoding destination subscription")
}
// Send Notification
resp, err := webpush.SendNotification(d, s, &webpush.Options{
VAPIDPublicKey: b.publicKey,
VAPIDPrivateKey: b.privateKey,
Topic: "owncast-go-live",
TTL: 10,
// Not really the subscriber, but a contact point for the sender.
Subscriber: "owncast@owncast.online",
})
if resp.StatusCode == 410 {
return true, nil
} else if err != nil {
return false, errors.Wrap(err, "error sending browser push notification")
}
defer resp.Body.Close()
return false, err
}

View File

@@ -0,0 +1,6 @@
package notifications
const (
// BrowserPushNotification represents a push notification for a browser.
BrowserPushNotification = "BROWSER_PUSH_NOTIFICATION"
)

View File

@@ -0,0 +1,61 @@
package discord
import (
"bytes"
"encoding/json"
"net/http"
"github.com/pkg/errors"
)
// Discord is an instance of the Discord service.
type Discord struct {
name string
avatar string
webhookURL string
}
// New will create a new instance of the Discord service.
func New(name, avatar, webhook string) (*Discord, error) {
return &Discord{
name: name,
avatar: avatar,
webhookURL: webhook,
}, nil
}
// Send will send a message to a Discord channel via a webhook.
func (t *Discord) Send(content string) error {
type message struct {
Username string `json:"username"`
Content string `json:"content"`
Avatar string `json:"avatar_url"`
}
msg := message{
Username: t.name,
Content: content,
Avatar: t.avatar,
}
jsonText, err := json.Marshal(msg)
if err != nil {
return errors.Wrap(err, "error marshalling discord message to json")
}
req, err := http.NewRequest("POST", t.webhookURL, bytes.NewReader(jsonText))
if err != nil {
return errors.Wrap(err, "error creating discord webhook request")
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return errors.Wrap(err, "error executing discord webhook")
}
return resp.Body.Close()
}

View File

@@ -0,0 +1,193 @@
package notifications
import (
"fmt"
"strings"
"github.com/owncast/owncast/config"
"github.com/owncast/owncast/core/data"
"github.com/owncast/owncast/models"
"github.com/owncast/owncast/notifications/browser"
"github.com/owncast/owncast/notifications/discord"
"github.com/owncast/owncast/notifications/twitter"
"github.com/owncast/owncast/utils"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
)
// Notifier is an instance of the live stream notifier.
type Notifier struct {
datastore *data.Datastore
browser *browser.Browser
discord *discord.Discord
twitter *twitter.Twitter
}
// Setup will perform any pre-use setup for the notifier.
func Setup(datastore *data.Datastore) {
createNotificationsTable(datastore.DB)
initializeBrowserPushIfNeeded()
}
func initializeBrowserPushIfNeeded() {
pubKey, _ := data.GetBrowserPushPublicKey()
privKey, _ := data.GetBrowserPushPrivateKey()
// We need browser push keys so people can register for pushes.
if pubKey == "" || privKey == "" {
browserPrivateKey, browserPublicKey, err := browser.GenerateBrowserPushKeys()
if err != nil {
log.Errorln("unable to initialize browser push notification keys", err)
}
if err := data.SetBrowserPushPrivateKey(browserPrivateKey); err != nil {
log.Errorln("unable to set browser push private key", err)
}
if err := data.SetBrowserPushPublicKey(browserPublicKey); err != nil {
log.Errorln("unable to set browser push public key", err)
}
}
// Enable browser push notifications by default.
if !data.GetHasPerformedInitialNotificationsConfig() {
_ = data.SetBrowserPushConfig(models.BrowserNotificationConfiguration{Enabled: true, GoLiveMessage: config.GetDefaults().FederationGoLiveMessage})
_ = data.SetHasPerformedInitialNotificationsConfig(true)
}
}
// New creates a new instance of the Notifier.
func New(datastore *data.Datastore) (*Notifier, error) {
notifier := Notifier{
datastore: datastore,
}
if err := notifier.setupBrowserPush(); err != nil {
log.Error(err)
}
if err := notifier.setupDiscord(); err != nil {
log.Error(err)
}
if err := notifier.setupTwitter(); err != nil {
log.Errorln(err)
}
return &notifier, nil
}
func (n *Notifier) setupBrowserPush() error {
if data.GetBrowserPushConfig().Enabled {
publicKey, err := data.GetBrowserPushPublicKey()
if err != nil || publicKey == "" {
return errors.Wrap(err, "browser notifier disabled, failed to get browser push public key")
}
privateKey, err := data.GetBrowserPushPrivateKey()
if err != nil || privateKey == "" {
return errors.Wrap(err, "browser notifier disabled, failed to get browser push private key")
}
browserNotifier, err := browser.New(n.datastore, publicKey, privateKey)
if err != nil {
return errors.Wrap(err, "error creating browser notifier")
}
n.browser = browserNotifier
}
return nil
}
func (n *Notifier) notifyBrowserPush() {
destinations, err := GetNotificationDestinationsForChannel(BrowserPushNotification)
if err != nil {
log.Errorln("error getting browser push notification destinations", err)
}
for _, destination := range destinations {
unsubscribed, err := n.browser.Send(destination, data.GetServerName(), data.GetBrowserPushConfig().GoLiveMessage)
if unsubscribed {
// If the error is "unsubscribed", then remove the destination from the database.
if err := RemoveNotificationForChannel(BrowserPushNotification, destination); err != nil {
log.Errorln(err)
}
} else if err != nil {
log.Errorln(err)
}
}
}
func (n *Notifier) setupDiscord() error {
discordConfig := data.GetDiscordConfig()
if discordConfig.Enabled && discordConfig.Webhook != "" {
var image string
if serverURL := data.GetServerURL(); serverURL != "" {
image = serverURL + "/images/owncast-logo.png"
}
discordNotifier, err := discord.New(
data.GetServerName(),
image,
discordConfig.Webhook,
)
if err != nil {
return errors.Wrap(err, "error creating discord notifier")
}
n.discord = discordNotifier
}
return nil
}
func (n *Notifier) notifyDiscord() {
goLiveMessage := data.GetDiscordConfig().GoLiveMessage
streamTitle := data.GetStreamTitle()
if streamTitle != "" {
goLiveMessage += "\n" + streamTitle
}
message := fmt.Sprintf("%s\n\n%s", goLiveMessage, data.GetServerURL())
if err := n.discord.Send(message); err != nil {
log.Errorln("error sending discord message", err)
}
}
func (n *Notifier) setupTwitter() error {
if twitterConfig := data.GetTwitterConfiguration(); twitterConfig.Enabled {
if t, err := twitter.New(twitterConfig.APIKey, twitterConfig.APISecret, twitterConfig.AccessToken, twitterConfig.AccessTokenSecret, twitterConfig.BearerToken); err == nil {
n.twitter = t
} else if err != nil {
return errors.Wrap(err, "error creating twitter notifier")
}
}
return nil
}
func (n *Notifier) notifyTwitter() {
goLiveMessage := data.GetTwitterConfiguration().GoLiveMessage
streamTitle := data.GetStreamTitle()
if streamTitle != "" {
goLiveMessage += "\n" + streamTitle
}
tagString := ""
for _, tag := range utils.ShuffleStringSlice(data.GetServerMetadataTags()) {
tagString = fmt.Sprintf("%s #%s", tagString, tag)
}
tagString = strings.TrimSpace(tagString)
message := fmt.Sprintf("%s\n%s\n\n%s", goLiveMessage, data.GetServerURL(), tagString)
if err := n.twitter.Notify(message); err != nil {
log.Errorln("error sending twitter message", err)
}
}
// Notify will fire the different notification channels.
func (n *Notifier) Notify() {
if n.browser != nil {
n.notifyBrowserPush()
}
if n.discord != nil {
n.notifyDiscord()
}
if n.twitter != nil {
n.notifyTwitter()
}
}

View File

@@ -0,0 +1,60 @@
package notifications
import (
"context"
"database/sql"
"github.com/owncast/owncast/core/data"
"github.com/owncast/owncast/db"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
)
func createNotificationsTable(db *sql.DB) {
log.Traceln("Creating federation followers table...")
createTableSQL := `CREATE TABLE IF NOT EXISTS notifications (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"channel" TEXT NOT NULL,
"destination" TEXT NOT NULL,
"created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
CREATE INDEX channel_index ON notifications (channel);`
stmt, err := db.Prepare(createTableSQL)
if err != nil {
log.Fatal(err)
}
defer stmt.Close()
_, err = stmt.Exec()
if err != nil {
log.Warnln("error executing sql creating followers table", createTableSQL, err)
}
}
// AddNotification saves a new user notification destination.
func AddNotification(channel, destination string) error {
return data.GetDatastore().GetQueries().AddNotification(context.Background(), db.AddNotificationParams{
Channel: channel,
Destination: destination,
})
}
// RemoveNotificationForChannel removes a notification destination..
func RemoveNotificationForChannel(channel, destination string) error {
log.Println("Removing notification for channel", channel)
return data.GetDatastore().GetQueries().RemoveNotificationDestinationForChannel(context.Background(), db.RemoveNotificationDestinationForChannelParams{
Channel: channel,
Destination: destination,
})
}
// GetNotificationDestinationsForChannel will return a collection of
// destinations to notify for a given channel.
func GetNotificationDestinationsForChannel(channel string) ([]string, error) {
result, err := data.GetDatastore().GetQueries().GetNotificationDestinationsForChannel(context.Background(), channel)
if err != nil {
return nil, errors.Wrap(err, "unable to query notification destinations for channel "+channel)
}
return result, nil
}

View File

@@ -0,0 +1,78 @@
package twitter
import (
"context"
"errors"
"fmt"
"net/http"
"github.com/dghubble/oauth1"
"github.com/g8rswimmer/go-twitter/v2"
)
/*
1. developer.twitter.com. Apply to be a developer if needed.
2. Projects and apps -> Your project name
3. Settings.
4. Scroll down to"User authentication settings" Edit
5. Enable OAuth 1.0a with Read/Write permissions.
6. Fill out the form with your information. Callback can be anything.
7. Go to your project "Keys and tokens"
8. Generate API key and secret.
9. Generate access token and secret. Verify it says "Read and write permissions."
10. Generate bearer token.
*/
type authorize struct {
Token string
}
func (a authorize) Add(req *http.Request) {
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", a.Token))
}
// Twitter is an instance of the Twitter notifier.
type Twitter struct {
apiKey string
apiSecret string
accessToken string
accessTokenSecret string
bearerToken string
}
// New returns a new instance of the Twitter notifier.
func New(apiKey, apiSecret, accessToken, accessTokenSecret, bearerToken string) (*Twitter, error) {
if apiKey == "" || apiSecret == "" || accessToken == "" || accessTokenSecret == "" || bearerToken == "" {
return nil, errors.New("missing some or all of the required twitter configuration values")
}
return &Twitter{
apiKey: apiKey,
apiSecret: apiSecret,
accessToken: accessToken,
accessTokenSecret: accessTokenSecret,
bearerToken: bearerToken,
}, nil
}
// Notify will send a notification to Twitter with the supplied text.
func (t *Twitter) Notify(text string) error {
config := oauth1.NewConfig(t.apiKey, t.apiSecret)
token := oauth1.NewToken(t.accessToken, t.accessTokenSecret)
httpClient := config.Client(oauth1.NoContext, token)
client := &twitter.Client{
Authorizer: authorize{
Token: t.bearerToken,
},
Client: httpClient,
Host: "https://api.twitter.com",
}
req := twitter.CreateTweetRequest{
Text: text,
}
_, err := client.CreateTweet(context.Background(), req)
return err
}