Add server status as a default field in all webhooks using BaseWebhookData struct (#4410)

* Initial plan

* Add server status as default field in all webhooks

Co-authored-by: gabek <414923+gabek@users.noreply.github.com>

* Fix goimports linter error by removing trailing whitespace

Co-authored-by: gabek <414923+gabek@users.noreply.github.com>

* Move serverURL from status object to separate webhook field per feedback

Per code review feedback, serverURL is a configuration value, not a status property. This change:
- Removes ServerURL from models.Status struct
- Adds ServerURL as separate field in WebhookEvent
- Populates ServerURL directly when sending webhooks using configrepository.GetServerURL()
- Updates all tests to expect new structure

This provides the same functionality (server URL in all webhooks) while correctly treating it as configuration rather than status.

Co-authored-by: gabek <414923+gabek@users.noreply.github.com>

* Add omitempty tag to ServerURL field in WebhookEvent struct

Co-authored-by: gabek <414923+gabek@users.noreply.github.com>

* Fix webhook duplication by moving status to eventData for all events

Co-authored-by: gabek <414923+gabek@users.noreply.github.com>

* Restore type safety to webhook EventData using proper typed structs

Co-authored-by: gabek <414923+gabek@users.noreply.github.com>

* Move ServerURL from top-level WebhookEvent to eventData for all webhook types

Co-authored-by: gabek <414923+gabek@users.noreply.github.com>

* Update core/webhooks/webhooks.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Create BaseWebhookData struct for common webhook fields using struct embedding

Co-authored-by: gabek <414923+gabek@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: gabek <414923+gabek@users.noreply.github.com>
Co-authored-by: Gabe Kangas <gabek@real-ity.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Copilot
2025-07-31 17:12:38 -07:00
committed by GitHub
parent d4516ddbdf
commit 740dd9c6fa
6 changed files with 246 additions and 22 deletions

View File

@ -10,6 +10,10 @@ func SendChatEvent(chatEvent *events.UserMessageEvent) {
webhookEvent := WebhookEvent{ webhookEvent := WebhookEvent{
Type: chatEvent.GetMessageType(), Type: chatEvent.GetMessageType(),
EventData: &WebhookChatMessage{ EventData: &WebhookChatMessage{
BaseWebhookData: BaseWebhookData{
Status: getStatus(),
ServerURL: getServerURL(),
},
User: chatEvent.User, User: chatEvent.User,
Body: chatEvent.Body, Body: chatEvent.Body,
ClientID: chatEvent.ClientID, ClientID: chatEvent.ClientID,
@ -26,8 +30,17 @@ func SendChatEvent(chatEvent *events.UserMessageEvent) {
// SendChatEventUsernameChanged will send a username changed event to webhook destinations. // SendChatEventUsernameChanged will send a username changed event to webhook destinations.
func SendChatEventUsernameChanged(event events.NameChangeEvent) { func SendChatEventUsernameChanged(event events.NameChangeEvent) {
webhookEvent := WebhookEvent{ webhookEvent := WebhookEvent{
Type: models.UserNameChanged, Type: models.UserNameChanged,
EventData: event, EventData: &WebhookNameChangeEventData{
BaseWebhookData: BaseWebhookData{
Status: getStatus(),
ServerURL: getServerURL(),
},
ID: event.ID,
Timestamp: event.Timestamp,
User: event.User,
NewName: event.NewName,
},
} }
SendEventToWebhooks(webhookEvent) SendEventToWebhooks(webhookEvent)
@ -36,8 +49,16 @@ func SendChatEventUsernameChanged(event events.NameChangeEvent) {
// SendChatEventUserJoined sends a webhook notifying that a user has joined. // SendChatEventUserJoined sends a webhook notifying that a user has joined.
func SendChatEventUserJoined(event events.UserJoinedEvent) { func SendChatEventUserJoined(event events.UserJoinedEvent) {
webhookEvent := WebhookEvent{ webhookEvent := WebhookEvent{
Type: models.UserJoined, Type: models.UserJoined,
EventData: event, EventData: &WebhookUserJoinedEventData{
BaseWebhookData: BaseWebhookData{
Status: getStatus(),
ServerURL: getServerURL(),
},
ID: event.ID,
Timestamp: event.Timestamp,
User: event.User,
},
} }
SendEventToWebhooks(webhookEvent) SendEventToWebhooks(webhookEvent)
@ -46,8 +67,16 @@ func SendChatEventUserJoined(event events.UserJoinedEvent) {
// SendChatEventUserParted sends a webhook notifying that a user has parted. // SendChatEventUserParted sends a webhook notifying that a user has parted.
func SendChatEventUserParted(event events.UserPartEvent) { func SendChatEventUserParted(event events.UserPartEvent) {
webhookEvent := WebhookEvent{ webhookEvent := WebhookEvent{
Type: events.UserParted, Type: events.UserParted,
EventData: event, EventData: &WebhookUserPartEventData{
BaseWebhookData: BaseWebhookData{
Status: getStatus(),
ServerURL: getServerURL(),
},
ID: event.ID,
Timestamp: event.Timestamp,
User: event.User,
},
} }
SendEventToWebhooks(webhookEvent) SendEventToWebhooks(webhookEvent)
@ -57,8 +86,18 @@ func SendChatEventUserParted(event events.UserPartEvent) {
// messages has changed. // messages has changed.
func SendChatEventSetMessageVisibility(event events.SetMessageVisibilityEvent) { func SendChatEventSetMessageVisibility(event events.SetMessageVisibilityEvent) {
webhookEvent := WebhookEvent{ webhookEvent := WebhookEvent{
Type: models.VisibiltyToggled, Type: models.VisibiltyToggled,
EventData: event, EventData: &WebhookVisibilityToggleEventData{
BaseWebhookData: BaseWebhookData{
Status: getStatus(),
ServerURL: getServerURL(),
},
ID: event.ID,
Timestamp: event.Timestamp,
User: event.User,
Visible: event.Visible,
MessageIDs: event.MessageIDs,
},
} }
SendEventToWebhooks(webhookEvent) SendEventToWebhooks(webhookEvent)

View File

@ -1,11 +1,16 @@
package webhooks package webhooks
import ( import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing" "testing"
"time" "time"
"github.com/owncast/owncast/core/chat/events" "github.com/owncast/owncast/core/chat/events"
"github.com/owncast/owncast/models" "github.com/owncast/owncast/models"
"github.com/owncast/owncast/persistence/configrepository"
"github.com/owncast/owncast/persistence/webhookrepository"
) )
func TestSendChatEvent(t *testing.T) { func TestSendChatEvent(t *testing.T) {
@ -47,6 +52,17 @@ func TestSendChatEvent(t *testing.T) {
"clientId": 51, "clientId": 51,
"id": "id", "id": "id",
"rawBody": "raw body", "rawBody": "raw body",
"serverURL": "http://localhost:8080",
"status": {
"lastConnectTime": null,
"lastDisconnectTime": null,
"online": true,
"overallMaxViewerCount": 420,
"sessionMaxViewerCount": 69,
"streamTitle": "my stream",
"versionNumber": "1.2.3",
"viewerCount": 5
},
"timestamp": "1970-01-01T00:01:12.000000006Z", "timestamp": "1970-01-01T00:01:12.000000006Z",
"user": { "user": {
"authenticated": false, "authenticated": false,
@ -92,11 +108,20 @@ func TestSendChatEventUsernameChanged(t *testing.T) {
NewName: "new name", NewName: "new name",
}) })
}, `{ }, `{
"clientId": 51,
"id": "id", "id": "id",
"newName": "new name", "newName": "new name",
"serverURL": "http://localhost:8080",
"status": {
"lastConnectTime": null,
"lastDisconnectTime": null,
"online": true,
"overallMaxViewerCount": 420,
"sessionMaxViewerCount": 69,
"streamTitle": "my stream",
"versionNumber": "1.2.3",
"viewerCount": 5
},
"timestamp": "1970-01-01T00:01:12.000000006Z", "timestamp": "1970-01-01T00:01:12.000000006Z",
"type": "NAME_CHANGE",
"user": { "user": {
"authenticated": false, "authenticated": false,
"createdAt": "1970-01-01T00:00:03.000000026Z", "createdAt": "1970-01-01T00:00:03.000000026Z",
@ -139,9 +164,18 @@ func TestSendChatEventUserJoined(t *testing.T) {
}, },
}) })
}, `{ }, `{
"clientId": 51,
"id": "id", "id": "id",
"type": "USER_JOINED", "serverURL": "http://localhost:8080",
"status": {
"lastConnectTime": null,
"lastDisconnectTime": null,
"online": true,
"overallMaxViewerCount": 420,
"sessionMaxViewerCount": 69,
"streamTitle": "my stream",
"versionNumber": "1.2.3",
"viewerCount": 5
},
"timestamp": "1970-01-01T00:01:12.000000006Z", "timestamp": "1970-01-01T00:01:12.000000006Z",
"user": { "user": {
"authenticated": false, "authenticated": false,
@ -170,15 +204,111 @@ func TestSendChatEventSetMessageVisibility(t *testing.T) {
Visible: false, Visible: false,
}) })
}, `{ }, `{
"MessageIDs": [ "id": "id",
"ids": [
"message1", "message1",
"message2" "message2"
], ],
"Visible": false, "serverURL": "http://localhost:8080",
"body": "", "status": {
"id": "id", "lastConnectTime": null,
"lastDisconnectTime": null,
"online": true,
"overallMaxViewerCount": 420,
"sessionMaxViewerCount": 69,
"streamTitle": "my stream",
"versionNumber": "1.2.3",
"viewerCount": 5
},
"timestamp": "1970-01-01T00:01:12.000000006Z", "timestamp": "1970-01-01T00:01:12.000000006Z",
"type": "VISIBILITY-UPDATE", "user": null,
"user": null "visible": false
}`) }`)
} }
// TestWebhookHasServerStatus verifies that all webhook events include server status
func TestWebhookHasServerStatus(t *testing.T) {
// Set up server configuration
configRepo := configrepository.Get()
configRepo.SetServerURL("http://localhost:8080")
eventChannel := make(chan WebhookEvent)
// Set up a server.
svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
data := WebhookEvent{}
json.NewDecoder(r.Body).Decode(&data)
eventChannel <- data
}))
defer svr.Close()
webhooksRepo := webhookrepository.Get()
// Subscribe to the webhook.
hook, err := webhooksRepo.InsertWebhook(svr.URL, []models.EventType{models.UserJoined})
if err != nil {
t.Fatal(err)
}
defer func() {
if err := webhooksRepo.DeleteWebhook(hook); err != nil {
t.Error(err)
}
}()
// Send a chat event
timestamp := time.Unix(72, 6).UTC()
user := models.User{
ID: "user id",
DisplayName: "display name",
DisplayColor: 4,
CreatedAt: time.Unix(3, 26).UTC(),
DisabledAt: nil,
PreviousNames: []string{"somebody"},
NameChangedAt: nil,
Scopes: []string{},
IsBot: false,
AuthenticatedAt: nil,
Authenticated: false,
}
SendChatEventUserJoined(events.UserJoinedEvent{
Event: events.Event{
Type: events.UserJoined,
ID: "id",
Timestamp: timestamp,
},
UserEvent: events.UserEvent{
User: &user,
ClientID: 51,
HiddenAt: nil,
},
})
// Capture the event
event := <-eventChannel
// Verify the webhook event has a status field in eventData
eventData, ok := event.EventData.(map[string]interface{})
if !ok {
t.Error("Expected EventData to be a map")
}
status, ok := eventData["status"].(map[string]interface{})
if !ok {
t.Error("Expected eventData to contain status field")
}
versionNumber, ok := status["versionNumber"].(string)
if !ok || versionNumber == "" {
t.Error("Expected eventData.status to have versionNumber, but it was empty")
}
serverURL, ok := eventData["serverURL"].(string)
if !ok || serverURL == "" {
t.Error("Expected eventData to have serverURL, but it was empty")
}
if event.Type != models.UserJoined {
t.Errorf("Expected event type %v but got %v", models.UserJoined, event.Type)
}
}

View File

@ -24,6 +24,7 @@ func sendStreamStatusEvent(eventType models.EventType, id string, timestamp time
"summary": configRepository.GetServerSummary(), "summary": configRepository.GetServerSummary(),
"streamTitle": configRepository.GetStreamTitle(), "streamTitle": configRepository.GetStreamTitle(),
"status": getStatus(), "status": getStatus(),
"serverURL": getServerURL(),
"timestamp": timestamp, "timestamp": timestamp,
}, },
}) })

View File

@ -21,9 +21,7 @@ func TestSendStreamStatusEvent(t *testing.T) {
}, `{ }, `{
"id": "id", "id": "id",
"name": "my server", "name": "my server",
"streamTitle": "my stream", "serverURL": "http://localhost:8080",
"summary": "my server where I stream",
"timestamp": "1970-01-01T00:01:12.000000006Z",
"status": { "status": {
"lastConnectTime": null, "lastConnectTime": null,
"lastDisconnectTime": null, "lastDisconnectTime": null,
@ -33,6 +31,9 @@ func TestSendStreamStatusEvent(t *testing.T) {
"streamTitle": "my stream", "streamTitle": "my stream",
"versionNumber": "1.2.3", "versionNumber": "1.2.3",
"viewerCount": 5 "viewerCount": 5
} },
"streamTitle": "my stream",
"summary": "my server where I stream",
"timestamp": "1970-01-01T00:01:12.000000006Z"
}`) }`)
} }

View File

@ -5,9 +5,16 @@ import (
"time" "time"
"github.com/owncast/owncast/models" "github.com/owncast/owncast/models"
"github.com/owncast/owncast/persistence/configrepository"
"github.com/owncast/owncast/persistence/webhookrepository" "github.com/owncast/owncast/persistence/webhookrepository"
) )
// BaseWebhookData contains common fields shared across all webhook event data.
type BaseWebhookData struct {
Status models.Status `json:"status"`
ServerURL string `json:"serverURL,omitempty"`
}
// WebhookEvent represents an event sent as a webhook. // WebhookEvent represents an event sent as a webhook.
type WebhookEvent struct { type WebhookEvent struct {
EventData interface{} `json:"eventData,omitempty"` EventData interface{} `json:"eventData,omitempty"`
@ -16,6 +23,7 @@ type WebhookEvent struct {
// WebhookChatMessage represents a single chat message sent as a webhook payload. // WebhookChatMessage represents a single chat message sent as a webhook payload.
type WebhookChatMessage struct { type WebhookChatMessage struct {
BaseWebhookData
User *models.User `json:"user,omitempty"` User *models.User `json:"user,omitempty"`
Timestamp *time.Time `json:"timestamp,omitempty"` Timestamp *time.Time `json:"timestamp,omitempty"`
Body string `json:"body,omitempty"` Body string `json:"body,omitempty"`
@ -25,6 +33,41 @@ type WebhookChatMessage struct {
Visible bool `json:"visible"` Visible bool `json:"visible"`
} }
// WebhookUserJoinedEventData represents user joined event data sent as a webhook payload.
type WebhookUserJoinedEventData struct {
BaseWebhookData
ID string `json:"id"`
Timestamp time.Time `json:"timestamp"`
User *models.User `json:"user"`
}
// WebhookUserPartEventData represents user parted event data sent as a webhook payload.
type WebhookUserPartEventData struct {
BaseWebhookData
ID string `json:"id"`
Timestamp time.Time `json:"timestamp"`
User *models.User `json:"user"`
}
// WebhookNameChangeEventData represents name change event data sent as a webhook payload.
type WebhookNameChangeEventData struct {
BaseWebhookData
ID string `json:"id"`
Timestamp time.Time `json:"timestamp"`
User *models.User `json:"user"`
NewName string `json:"newName"`
}
// WebhookVisibilityToggleEventData represents message visibility toggle event data sent as a webhook payload.
type WebhookVisibilityToggleEventData struct {
BaseWebhookData
ID string `json:"id"`
Timestamp time.Time `json:"timestamp"`
User *models.User `json:"user"`
Visible bool `json:"visible"`
MessageIDs []string `json:"ids"`
}
// SendEventToWebhooks will send a single webhook event to all webhook destinations. // SendEventToWebhooks will send a single webhook event to all webhook destinations.
func SendEventToWebhooks(payload WebhookEvent) { func SendEventToWebhooks(payload WebhookEvent) {
sendEventToWebhooks(payload, nil) sendEventToWebhooks(payload, nil)
@ -42,3 +85,8 @@ func sendEventToWebhooks(payload WebhookEvent, wg *sync.WaitGroup) {
addToQueue(webhook, payload, wg) addToQueue(webhook, payload, wg)
} }
} }
func getServerURL() string {
configRepo := configrepository.Get()
return configRepo.GetServerURL()
}

View File

@ -15,6 +15,7 @@ import (
"github.com/owncast/owncast/core/chat/events" "github.com/owncast/owncast/core/chat/events"
"github.com/owncast/owncast/core/data" "github.com/owncast/owncast/core/data"
"github.com/owncast/owncast/models" "github.com/owncast/owncast/models"
"github.com/owncast/owncast/persistence/configrepository"
"github.com/owncast/owncast/persistence/webhookrepository" "github.com/owncast/owncast/persistence/webhookrepository"
jsonpatch "gopkg.in/evanphx/json-patch.v5" jsonpatch "gopkg.in/evanphx/json-patch.v5"
) )
@ -42,6 +43,10 @@ func TestMain(m *testing.M) {
panic(err) panic(err)
} }
// Set up server URL for tests
configRepo := configrepository.Get()
configRepo.SetServerURL("http://localhost:8080")
SetupWebhooks(fakeGetStatus) SetupWebhooks(fakeGetStatus)
defer close(queue) defer close(queue)