From 740dd9c6fa8abc2cc4a9699a0aa59e0818a2bd60 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 31 Jul 2025 17:12:38 -0700 Subject: [PATCH] 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 Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- core/webhooks/chat.go | 55 ++++++++++-- core/webhooks/chat_test.go | 150 ++++++++++++++++++++++++++++++--- core/webhooks/stream.go | 1 + core/webhooks/stream_test.go | 9 +- core/webhooks/webhooks.go | 48 +++++++++++ core/webhooks/webhooks_test.go | 5 ++ 6 files changed, 246 insertions(+), 22 deletions(-) diff --git a/core/webhooks/chat.go b/core/webhooks/chat.go index a7635371d7..9505061b8a 100644 --- a/core/webhooks/chat.go +++ b/core/webhooks/chat.go @@ -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) diff --git a/core/webhooks/chat_test.go b/core/webhooks/chat_test.go index f8448e444a..7a1fc3e4ce 100644 --- a/core/webhooks/chat_test.go +++ b/core/webhooks/chat_test.go @@ -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) + } +} diff --git a/core/webhooks/stream.go b/core/webhooks/stream.go index d15efe7327..01c33f12ce 100644 --- a/core/webhooks/stream.go +++ b/core/webhooks/stream.go @@ -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, }, }) diff --git a/core/webhooks/stream_test.go b/core/webhooks/stream_test.go index 651bbe7002..3100e08674 100644 --- a/core/webhooks/stream_test.go +++ b/core/webhooks/stream_test.go @@ -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" }`) } diff --git a/core/webhooks/webhooks.go b/core/webhooks/webhooks.go index 74478c4d1b..d42db85330 100644 --- a/core/webhooks/webhooks.go +++ b/core/webhooks/webhooks.go @@ -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() +} diff --git a/core/webhooks/webhooks_test.go b/core/webhooks/webhooks_test.go index 1b035471cc..17ddb182ac 100644 --- a/core/webhooks/webhooks_test.go +++ b/core/webhooks/webhooks_test.go @@ -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)