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

View File

@ -1,11 +1,16 @@
package webhooks
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/owncast/owncast/core/chat/events"
"github.com/owncast/owncast/models"
"github.com/owncast/owncast/persistence/configrepository"
"github.com/owncast/owncast/persistence/webhookrepository"
)
func TestSendChatEvent(t *testing.T) {
@ -47,6 +52,17 @@ func TestSendChatEvent(t *testing.T) {
"clientId": 51,
"id": "id",
"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",
"user": {
"authenticated": false,
@ -92,11 +108,20 @@ func TestSendChatEventUsernameChanged(t *testing.T) {
NewName: "new name",
})
}, `{
"clientId": 51,
"id": "id",
"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",
"type": "NAME_CHANGE",
"user": {
"authenticated": false,
"createdAt": "1970-01-01T00:00:03.000000026Z",
@ -139,9 +164,18 @@ func TestSendChatEventUserJoined(t *testing.T) {
},
})
}, `{
"clientId": 51,
"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",
"user": {
"authenticated": false,
@ -170,15 +204,111 @@ func TestSendChatEventSetMessageVisibility(t *testing.T) {
Visible: false,
})
}, `{
"MessageIDs": [
"id": "id",
"ids": [
"message1",
"message2"
],
"Visible": false,
"body": "",
"id": "id",
"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",
"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(),
"streamTitle": configRepository.GetStreamTitle(),
"status": getStatus(),
"serverURL": getServerURL(),
"timestamp": timestamp,
},
})

View File

@ -21,9 +21,7 @@ func TestSendStreamStatusEvent(t *testing.T) {
}, `{
"id": "id",
"name": "my server",
"streamTitle": "my stream",
"summary": "my server where I stream",
"timestamp": "1970-01-01T00:01:12.000000006Z",
"serverURL": "http://localhost:8080",
"status": {
"lastConnectTime": null,
"lastDisconnectTime": null,
@ -33,6 +31,9 @@ func TestSendStreamStatusEvent(t *testing.T) {
"streamTitle": "my stream",
"versionNumber": "1.2.3",
"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"
"github.com/owncast/owncast/models"
"github.com/owncast/owncast/persistence/configrepository"
"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.
type WebhookEvent struct {
EventData interface{} `json:"eventData,omitempty"`
@ -16,6 +23,7 @@ type WebhookEvent struct {
// WebhookChatMessage represents a single chat message sent as a webhook payload.
type WebhookChatMessage struct {
BaseWebhookData
User *models.User `json:"user,omitempty"`
Timestamp *time.Time `json:"timestamp,omitempty"`
Body string `json:"body,omitempty"`
@ -25,6 +33,41 @@ type WebhookChatMessage struct {
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.
func SendEventToWebhooks(payload WebhookEvent) {
sendEventToWebhooks(payload, nil)
@ -42,3 +85,8 @@ func sendEventToWebhooks(payload WebhookEvent, wg *sync.WaitGroup) {
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/data"
"github.com/owncast/owncast/models"
"github.com/owncast/owncast/persistence/configrepository"
"github.com/owncast/owncast/persistence/webhookrepository"
jsonpatch "gopkg.in/evanphx/json-patch.v5"
)
@ -42,6 +43,10 @@ func TestMain(m *testing.M) {
panic(err)
}
// Set up server URL for tests
configRepo := configrepository.Get()
configRepo.SetServerURL("http://localhost:8080")
SetupWebhooks(fakeGetStatus)
defer close(queue)