diff --git a/controllers/admin/chat.go b/controllers/admin/chat.go index acd7acb2d..ee282c7e0 100644 --- a/controllers/admin/chat.go +++ b/controllers/admin/chat.go @@ -7,11 +7,13 @@ import ( "errors" "fmt" "net/http" + "strconv" "github.com/owncast/owncast/controllers" "github.com/owncast/owncast/core/chat" "github.com/owncast/owncast/core/chat/events" "github.com/owncast/owncast/core/user" + "github.com/owncast/owncast/utils" log "github.com/sirupsen/logrus" ) @@ -126,6 +128,31 @@ func SendSystemMessage(integration user.ExternalAPIUser, w http.ResponseWriter, controllers.WriteSimpleResponse(w, true, "sent") } +// SendSystemMessageToConnectedClient will handle incoming requests to send a single message to a single connected client by ID. +func SendSystemMessageToConnectedClient(integration user.ExternalAPIUser, w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + clientIDText, err := utils.ReadRestURLParameter(r, "clientId") + if err != nil { + controllers.BadRequestHandler(w, err) + return + } + + clientIDNumeric, err := strconv.ParseUint(clientIDText, 10, 32) + if err != nil { + controllers.BadRequestHandler(w, err) + return + } + + var message events.SystemMessageEvent + if err := json.NewDecoder(r.Body).Decode(&message); err != nil { + controllers.InternalErrorHandler(w, err) + return + } + + chat.SendSystemMessageToClient(uint(clientIDNumeric), message.Body) + controllers.WriteSimpleResponse(w, true, "sent") +} + // SendUserMessage will send a message to chat on behalf of a user. *Depreciated*. func SendUserMessage(integration user.ExternalAPIUser, w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") diff --git a/core/chat/chat.go b/core/chat/chat.go index bf5ab4f35..11735e99b 100644 --- a/core/chat/chat.go +++ b/core/chat/chat.go @@ -41,6 +41,12 @@ func GetClientsForUser(userID string) ([]*Client, error) { return clients[userID], nil } +// FindClientByID will return a single connected client by ID. +func FindClientByID(clientID uint) (*Client, bool) { + client, found := _server.clients[clientID] + return client, found +} + // GetClients will return all the current chat clients connected. func GetClients() []*Client { clients := []*Client{} @@ -105,6 +111,13 @@ func SendAllWelcomeMessage() { _server.sendAllWelcomeMessage() } +// SendSystemMessageToClient will send a single message to a single connected chat client. +func SendSystemMessageToClient(clientID uint, text string) { + if client, foundClient := FindClientByID(clientID); foundClient { + _server.sendSystemMessageToClient(client, text) + } +} + // Broadcast will send all connected clients the outbound object provided. func Broadcast(event events.OutboundEvent) error { return _server.Broadcast(event.GetBroadcastPayload()) diff --git a/core/chat/events.go b/core/chat/events.go index 7c2c664eb..18acd7709 100644 --- a/core/chat/events.go +++ b/core/chat/events.go @@ -66,6 +66,7 @@ func (s *Server) userNameChanged(eventData chatClientEvent) { // Send chat user name changed webhook receivedEvent.User = savedUser + receivedEvent.ClientID = eventData.client.id webhooks.SendChatEventUsernameChanged(receivedEvent) } @@ -77,6 +78,7 @@ func (s *Server) userMessageSent(eventData chatClientEvent) { } event.SetDefaults() + event.ClientID = eventData.client.id // Ignore empty messages if event.Empty() { diff --git a/core/chat/events/events.go b/core/chat/events/events.go index 7f008e915..43dd8af1b 100644 --- a/core/chat/events/events.go +++ b/core/chat/events/events.go @@ -36,6 +36,7 @@ type Event struct { // UserEvent is an event with an associated user. type UserEvent struct { User *user.User `json:"user"` + ClientID uint `json:"clientId"` HiddenAt *time.Time `json:"hiddenAt,omitempty"` } diff --git a/core/chat/server.go b/core/chat/server.go index 72a41a191..bd3c2fed7 100644 --- a/core/chat/server.go +++ b/core/chat/server.go @@ -115,6 +115,7 @@ func (s *Server) sendUserJoinedMessage(c *Client) { userJoinedEvent := events.UserJoinedEvent{} userJoinedEvent.SetDefaults() userJoinedEvent.User = c.User + userJoinedEvent.ClientID = c.id if err := s.Broadcast(userJoinedEvent.GetBroadcastPayload()); err != nil { log.Errorln("error adding client to chat server", err) @@ -317,6 +318,7 @@ func (s *Server) sendSystemMessageToClient(c *Client, message string) { }, } clientMessage.SetDefaults() + clientMessage.RenderBody() s.Send(clientMessage.GetBroadcastPayload(), c) } diff --git a/core/webhooks/chat.go b/core/webhooks/chat.go index 019eb3cef..3647956b6 100644 --- a/core/webhooks/chat.go +++ b/core/webhooks/chat.go @@ -12,6 +12,7 @@ func SendChatEvent(chatEvent *events.UserMessageEvent) { EventData: &WebhookChatMessage{ User: chatEvent.User, Body: chatEvent.Body, + ClientID: chatEvent.ClientID, RawBody: chatEvent.RawBody, ID: chatEvent.ID, Visible: chatEvent.HiddenAt == nil, diff --git a/core/webhooks/webhooks.go b/core/webhooks/webhooks.go index 057373c5a..25feb1ea5 100644 --- a/core/webhooks/webhooks.go +++ b/core/webhooks/webhooks.go @@ -23,6 +23,7 @@ type WebhookEvent struct { // WebhookChatMessage represents a single chat message sent as a webhook payload. type WebhookChatMessage struct { User *user.User `json:"user,omitempty"` + ClientID uint `json:"clientId,omitempty"` Body string `json:"body,omitempty"` RawBody string `json:"rawBody,omitempty"` ID string `json:"id,omitempty"` diff --git a/openapi.yaml b/openapi.yaml index fe74da76f..51bbadd2a 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -1398,6 +1398,58 @@ paths: type: string example: sent + /api/integrations/chat/system/client/{clientId}: + post: + summary: Send system chat message to a client, identified by its ClientId + description: Send a chat message on behalf of the system/server to a single client. + tags: ["Integrations"] + security: + - AccessToken: [] + parameters: + - name: clientId + in: path + description: Client ID (a unique numeric Id, identifying the client connection) + required: true + schema: + type: integer + format: int64 + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - "body" + properties: + body: + type: string + description: The message text that will be sent to the client. + example: "What a beautiful day. I love it" + responses: + "200": + description: Message was sent. + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + example: true + messages: + type: string + example: sent + "500": + description: Message was not sent to the client + content: + application/json: + schema: + type: object + properties: + error: + type: string + description: message explaining what went wrong sending the message to the client /api/admin/accesstokens/create: post: diff --git a/router/router.go b/router/router.go index 3f663ec2b..a14f59ba2 100644 --- a/router/router.go +++ b/router/router.go @@ -13,6 +13,7 @@ import ( "github.com/owncast/owncast/core/chat" "github.com/owncast/owncast/core/user" "github.com/owncast/owncast/router/middleware" + "github.com/owncast/owncast/utils" "github.com/owncast/owncast/yp" ) @@ -160,6 +161,9 @@ func Start() error { // Send a system message to chat http.HandleFunc("/api/integrations/chat/system", middleware.RequireExternalAPIAccessToken(user.ScopeCanSendSystemMessages, admin.SendSystemMessage)) + // Send a system message to a single client + http.HandleFunc(utils.RestEndpoint("/api/integrations/chat/system/client/{clientId}", middleware.RequireExternalAPIAccessToken(user.ScopeCanSendSystemMessages, admin.SendSystemMessageToConnectedClient))) + // Send a user message to chat *NO LONGER SUPPORTED http.HandleFunc("/api/integrations/chat/user", middleware.RequireExternalAPIAccessToken(user.ScopeCanSendChatMessages, admin.SendUserMessage)) diff --git a/utils/restendpointhelper.go b/utils/restendpointhelper.go new file mode 100644 index 000000000..41fb97877 --- /dev/null +++ b/utils/restendpointhelper.go @@ -0,0 +1,69 @@ +package utils + +import ( + "errors" + "fmt" + "net/http" + "strings" +) + +const restURLPatternHeaderKey = "Owncast-Resturl-Pattern" + +// takes the segment pattern of an Url string and returns the segment before the first dynamic REST parameter. +func getPatternForRestEndpoint(pattern string) string { + firstIndex := strings.Index(pattern, "/{") + if firstIndex == -1 { + return pattern + } + + return strings.TrimRight(pattern[:firstIndex], "/") + "/" +} + +func zip2D(iterable1 *[]string, iterable2 *[]string) map[string]string { + var dict = make(map[string]string) + for index, key := range *iterable1 { + dict[key] = (*iterable2)[index] + } + return dict +} + +func mapPatternWithRequestURL(pattern string, requestURL string) (map[string]string, error) { + patternSplit := strings.Split(pattern, "/") + requestURLSplit := strings.Split(requestURL, "/") + + if len(patternSplit) == len(requestURLSplit) { + return zip2D(&patternSplit, &requestURLSplit), nil + } + return nil, errors.New("The length of pattern and request Url does not match") +} + +func readParameter(pattern string, requestURL string, paramName string) (string, error) { + all, err := mapPatternWithRequestURL(pattern, requestURL) + if err != nil { + return "", err + } + + if value, exists := all[fmt.Sprintf("{%s}", paramName)]; exists { + return value, nil + } + return "", fmt.Errorf("Parameter with name %s not found", paramName) +} + +// ReadRestURLParameter will return the parameter from the request of the requested name. +func ReadRestURLParameter(r *http.Request, parameterName string) (string, error) { + pattern, found := r.Header[restURLPatternHeaderKey] + if !found { + return "", fmt.Errorf("This HandlerFunc is not marked as REST-Endpoint. Cannot read Parameter '%s' from Request", parameterName) + } + + return readParameter(pattern[0], r.URL.Path, parameterName) +} + +// RestEndpoint wraps a handler to use the rest endpoint helper. +func RestEndpoint(pattern string, handler http.HandlerFunc) (string, http.HandlerFunc) { + baseURL := getPatternForRestEndpoint(pattern) + return baseURL, func(w http.ResponseWriter, r *http.Request) { + r.Header[restURLPatternHeaderKey] = []string{pattern} + handler(w, r) + } +} diff --git a/utils/restendpointhelper_test.go b/utils/restendpointhelper_test.go new file mode 100644 index 000000000..c9204f775 --- /dev/null +++ b/utils/restendpointhelper_test.go @@ -0,0 +1,39 @@ +package utils + +import ( + "strings" + "testing" +) + +func TestGetPatternForRestEndpoint(t *testing.T) { + expected := "/hello/" + endpoints := [...]string{"/hello/{param1}", "/hello/{param1}/{param2}", "/hello/{param1}/world/{param2}"} + for _, endpoint := range endpoints { + if ep := getPatternForRestEndpoint(endpoint); ep != expected { + t.Errorf("%s p does not match expected %s", ep, expected) + } + } +} + +func TestReadParameter(t *testing.T) { + expected := "world" + endpoints := [...]string{ + "/hello/{p1}", + "/hello/cruel/{p1}", + "/hello/{p1}/my/friend", + "/hello/{p1}/{p2}/friend", + "/hello/{p2}/{p3}/{p1}", + "/{p1}/is/nice", + "/{p1}/{p1}/{p1}", + } + + for _, ep := range endpoints { + v, err := readParameter(ep, strings.Replace(ep, "{p1}", expected, -1), "p1") + if err != nil { + t.Errorf("Unexpected error when reading parameter: %s", err.Error()) + } + if v != expected { + t.Errorf("'%s' should have returned %s", ep, expected) + } + } +}