feat: Adds a webhook for when a user follows the stream (#4362)

* feat: Adds a webhook for when a user follow the stream

Frontend:
Adds a checkbox which renders only when social features are enabled.

Backend:
- Defines a new type for the event
- Implements the webhook including unit testing
- Fires the webhook inside the ApproveFollower handler

* Removes unnecessary Type fiels from Events struct

* refactor: renames SendUserFollowedEvent func to FediverseEngagementFollow and fixes linting errors

* fix: fixes unit test to reflect removed Type field from FediverseEngagementFollowEvent

* feat: fires webhook also when manual approvals are not required

* chore: slight cleanup

---------

Co-authored-by: Gabe Kangas <gabek@real-ity.com>
This commit is contained in:
fortyoneplustwo
2025-11-21 13:47:50 -08:00
committed by GitHub
co-authored by Gabe Kangas
parent 9cc58d14c3
commit 80850b15d0
9 changed files with 123 additions and 12 deletions
+19
View File
@@ -0,0 +1,19 @@
package events
import (
"time"
"github.com/teris-io/shortid"
)
// Event is any kind of event.
type Event struct {
Timestamp time.Time `json:"timestamp"`
ID string `json:"id"`
}
// SetDefaults will set default properties of all inbound events.
func (e *Event) SetDefaults() {
e.ID = shortid.MustGenerate()
e.Timestamp = time.Now()
}
@@ -0,0 +1,8 @@
package events
type FediverseEngagementFollowEvent struct {
Event
Name string `json:"name"`
Username string `json:"username"`
Image string `json:"image"`
}
+6 -6
View File
@@ -10,6 +10,7 @@ import (
"github.com/owncast/owncast/activitypub/requests"
"github.com/owncast/owncast/activitypub/resolvers"
"github.com/owncast/owncast/core/chat/events"
"github.com/owncast/owncast/core/webhooks"
"github.com/owncast/owncast/persistence/configrepository"
"github.com/pkg/errors"
@@ -39,20 +40,19 @@ func handleFollowInboxRequest(c context.Context, activity vocab.ActivityStreamsF
}
localAccountName := configRepository.GetDefaultFederationUsername()
actorReference := activity.GetActivityStreamsActor()
object := activity.GetActivityStreamsObject()
objectIRI := object.At(0).GetIRI().String()
actorIRI := actorReference.At(0).GetIRI().String()
if approved {
if err := requests.SendFollowAccept(follow.Inbox, activity, localAccountName); err != nil {
log.Errorln("unable to send follow accept", err)
return err
}
go webhooks.SendFediverseEngagementFollowEvent(actorIRI)
}
// Save as an activity
actorReference := activity.GetActivityStreamsActor()
object := activity.GetActivityStreamsObject()
objectIRI := object.At(0).GetIRI().String()
actorIRI := actorReference.At(0).GetIRI().String()
// If this request is approved and we have not previously sent an action to
// chat due to a previous follow request, then do so.
hasPreviouslyhandled := true // Default so we don't send anything if it fails.
+32
View File
@@ -0,0 +1,32 @@
package webhooks
import (
"github.com/owncast/owncast/activitypub/events"
"github.com/owncast/owncast/activitypub/persistence"
"github.com/owncast/owncast/models"
)
// SendFediverseEventFollow will send a user followed event to webhook
// destinations.
func SendFediverseEngagementFollowEvent(iri string) {
follower, err := persistence.GetFollower(iri)
if err != nil {
return
}
userFollowedEvent := events.FediverseEngagementFollowEvent{}
userFollowedEvent.SetDefaults()
userFollowedEvent.Name = follower.Name
userFollowedEvent.Username = follower.Username
userFollowedEvent.Image = follower.Image.String()
sendFediverseEngagementEventFollow(userFollowedEvent)
}
func sendFediverseEngagementEventFollow(event events.FediverseEngagementFollowEvent) {
webhookEvent := WebhookEvent{
Type: models.FediverseEngagementFollow,
EventData: event,
}
SendEventToWebhooks(webhookEvent)
}
+28
View File
@@ -0,0 +1,28 @@
package webhooks
import (
"testing"
"time"
"github.com/owncast/owncast/activitypub/events"
"github.com/owncast/owncast/models"
)
func TestSendFediverseEngagementEventFollow(t *testing.T) {
checkPayload(t, models.FediverseEngagementFollow, func() {
sendFediverseEngagementEventFollow(events.FediverseEngagementFollowEvent{
Event: events.Event{
Timestamp: time.Unix(72, 6).UTC(),
ID: "id",
},
Name: "be",
Username: "be@witch.me",
})
}, `{
"id": "id",
"image": "",
"name": "be",
"timestamp": "1970-01-01T00:01:12.000000006Z",
"username": "be@witch.me"
}`)
}
+2
View File
@@ -12,6 +12,8 @@ const (
UserParted EventType = "USER_PARTED"
// UserNameChanged is the event sent when a chat username change takes place.
UserNameChanged EventType = "NAME_CHANGE"
// FediverseEngagementFollow is the event sent when a user follows the stream.
FediverseEngagementFollow EventType = "FEDIVERSE_ENGAGEMENT_FOLLOW"
// VisibiltyToggled is the event sent when a chat message's visibility changes.
VisibiltyToggled EventType = "VISIBILITY-UPDATE"
// PING is a ping message.
+1
View File
@@ -21,6 +21,7 @@ var validEvents = []EventType{
UserJoined,
UserParted,
UserNameChanged,
FediverseEngagementFollow,
VisibiltyToggled,
StreamStarted,
StreamStopped,
+19 -2
View File
@@ -13,7 +13,7 @@ import {
Tooltip,
} from 'antd';
import dynamic from 'next/dynamic';
import React, { ReactElement, useEffect, useState } from 'react';
import React, { ReactElement, useContext, useEffect, useState } from 'react';
import { useTranslation } from 'next-export-i18n';
import { CREATE_WEBHOOK, DELETE_WEBHOOK, fetchData, WEBHOOKS } from '../../utils/apis';
import { isValidUrl, DEFAULT_TEXTFIELD_URL_PATTERN } from '../../utils/validators';
@@ -21,6 +21,7 @@ import { Localization } from '../../types/localization';
import { Translation } from '../../components/ui/Translation/Translation';
import { AdminLayout } from '../../components/layouts/AdminLayout';
import { ServerStatusContext } from '../../utils/server-status-context';
const { Title, Paragraph } = Typography;
@@ -34,6 +35,11 @@ const availableEvents = {
CHAT: { name: 'Chat messages', description: 'When a user sends a chat message', color: 'purple' },
USER_JOINED: { name: 'User joined', description: 'When a user joins the chat', color: 'green' },
USER_PARTED: { name: 'User parted', description: 'When a user leaves the chat', color: 'green' },
FEDIVERSE_ENGAGEMENT_FOLLOW: {
name: 'New follower',
description: 'When a user follows the stream',
color: 'green',
},
NAME_CHANGE: {
name: 'User name changed',
description: 'When a user changes their name',
@@ -79,6 +85,8 @@ const NewWebhookModal = (props: Props) => {
const [selectedEvents, setSelectedEvents] = useState([]);
const [webhookUrl, setWebhookUrl] = useState('');
const { serverConfig } = useContext(ServerStatusContext);
const events = Object.keys(availableEvents).map(key => ({
value: key,
label: availableEvents[key].description,
@@ -104,7 +112,16 @@ const NewWebhookModal = (props: Props) => {
disabled: selectedEvents?.length === 0 || !isValidUrl(webhookUrl),
};
const checkboxes = events.map(singleEvent => (
const checkboxes = events
.filter(singleEvent => {
switch (singleEvent.value) {
case 'FEDIVERSE_ENGAGEMENT_FOLLOW':
return serverConfig.federation.enabled;
default:
return true;
}
})
.map(singleEvent => (
<Col span={8} key={singleEvent.value}>
<Checkbox value={singleEvent.value}>{singleEvent.label}</Checkbox>
</Col>
+4
View File
@@ -6,6 +6,7 @@ import (
"github.com/owncast/owncast/activitypub/persistence"
"github.com/owncast/owncast/activitypub/requests"
"github.com/owncast/owncast/core/webhooks"
"github.com/owncast/owncast/persistence/configrepository"
"github.com/owncast/owncast/webserver/handlers/generated"
webutils "github.com/owncast/owncast/webserver/utils"
@@ -36,6 +37,9 @@ func ApproveFollower(w http.ResponseWriter, r *http.Request) {
return
}
// Fire fediverse engagement follow event.
go webhooks.SendFediverseEngagementFollowEvent(*approval.ActorIRI)
configRepository := configrepository.Get()
localAccountName := configRepository.GetDefaultFederationUsername()