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:
83
notifications/browser/browser.go
Normal file
83
notifications/browser/browser.go
Normal 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
|
||||
}
|
||||
6
notifications/channels.go
Normal file
6
notifications/channels.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package notifications
|
||||
|
||||
const (
|
||||
// BrowserPushNotification represents a push notification for a browser.
|
||||
BrowserPushNotification = "BROWSER_PUSH_NOTIFICATION"
|
||||
)
|
||||
61
notifications/discord/discord.go
Normal file
61
notifications/discord/discord.go
Normal 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()
|
||||
}
|
||||
193
notifications/notifications.go
Normal file
193
notifications/notifications.go
Normal 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 ¬ifier, 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()
|
||||
}
|
||||
}
|
||||
60
notifications/persistence.go
Normal file
60
notifications/persistence.go
Normal 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
|
||||
}
|
||||
78
notifications/twitter/twitter.go
Normal file
78
notifications/twitter/twitter.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user