Feature/oc 1316 add support for system message to single user (#1351)

* add public func to lookup a ChatClient by its clientId

* add facility to send a system message directly to a user

* add clientId field to UserEvent

* implement simple http endpoint to send a message to a user

* let mux handle new directSystemMessageToUser endpoint

* add ClientId to UserEvents across the codebase

* render body of system-message to client

* add clientId to Chat-Message

* add tests showing how url-parsing should work

* add simple rest endpoint helpers for parameter-parsing and easy routing

* use newly added rest-endpoint helper to rout to Client-Messaging controller

* use safe "ReadRestUrlParameter" to parse ClientId

* remove empty HandleFunc in router

* set Header directly to prevent built-in (platform-dependent) canonicalization to kick in

* fix typo in "Parameter" message

* remove debug-logging of HTTP headers in REST-helpers

* convert to uint32 to prevent overruns when converting to wraptype uint later on

* resolve linter-ouchies

* resolve linter potential nil-deref warning

* document the SendSystemMessageToClient endpoint in swaggerdoc

* remove clientId assignment causing potential nil dereference in userDisabledEvent-case

as the clientId isn't relevant here anyway

* make findClientById private, so its not accessible outside of core/chat

* remove redundant string type hint

* Update PR based on linter requirements

Co-authored-by: Raffael Rehberger <raffael@rtrace.io>
Co-authored-by: Gabe Kangas <gabek@real-ity.com>
This commit is contained in:
Ruffy 2021-09-13 10:26:28 +02:00 committed by GitHub
parent 608888317b
commit 9c484efd36
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 211 additions and 0 deletions

View File

@ -7,11 +7,13 @@ import (
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
"strconv"
"github.com/owncast/owncast/controllers" "github.com/owncast/owncast/controllers"
"github.com/owncast/owncast/core/chat" "github.com/owncast/owncast/core/chat"
"github.com/owncast/owncast/core/chat/events" "github.com/owncast/owncast/core/chat/events"
"github.com/owncast/owncast/core/user" "github.com/owncast/owncast/core/user"
"github.com/owncast/owncast/utils"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
@ -126,6 +128,31 @@ func SendSystemMessage(integration user.ExternalAPIUser, w http.ResponseWriter,
controllers.WriteSimpleResponse(w, true, "sent") 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*. // SendUserMessage will send a message to chat on behalf of a user. *Depreciated*.
func SendUserMessage(integration user.ExternalAPIUser, w http.ResponseWriter, r *http.Request) { func SendUserMessage(integration user.ExternalAPIUser, w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")

View File

@ -41,6 +41,12 @@ func GetClientsForUser(userID string) ([]*Client, error) {
return clients[userID], nil 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. // GetClients will return all the current chat clients connected.
func GetClients() []*Client { func GetClients() []*Client {
clients := []*Client{} clients := []*Client{}
@ -105,6 +111,13 @@ func SendAllWelcomeMessage() {
_server.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. // Broadcast will send all connected clients the outbound object provided.
func Broadcast(event events.OutboundEvent) error { func Broadcast(event events.OutboundEvent) error {
return _server.Broadcast(event.GetBroadcastPayload()) return _server.Broadcast(event.GetBroadcastPayload())

View File

@ -66,6 +66,7 @@ func (s *Server) userNameChanged(eventData chatClientEvent) {
// Send chat user name changed webhook // Send chat user name changed webhook
receivedEvent.User = savedUser receivedEvent.User = savedUser
receivedEvent.ClientID = eventData.client.id
webhooks.SendChatEventUsernameChanged(receivedEvent) webhooks.SendChatEventUsernameChanged(receivedEvent)
} }
@ -77,6 +78,7 @@ func (s *Server) userMessageSent(eventData chatClientEvent) {
} }
event.SetDefaults() event.SetDefaults()
event.ClientID = eventData.client.id
// Ignore empty messages // Ignore empty messages
if event.Empty() { if event.Empty() {

View File

@ -36,6 +36,7 @@ type Event struct {
// UserEvent is an event with an associated user. // UserEvent is an event with an associated user.
type UserEvent struct { type UserEvent struct {
User *user.User `json:"user"` User *user.User `json:"user"`
ClientID uint `json:"clientId"`
HiddenAt *time.Time `json:"hiddenAt,omitempty"` HiddenAt *time.Time `json:"hiddenAt,omitempty"`
} }

View File

@ -115,6 +115,7 @@ func (s *Server) sendUserJoinedMessage(c *Client) {
userJoinedEvent := events.UserJoinedEvent{} userJoinedEvent := events.UserJoinedEvent{}
userJoinedEvent.SetDefaults() userJoinedEvent.SetDefaults()
userJoinedEvent.User = c.User userJoinedEvent.User = c.User
userJoinedEvent.ClientID = c.id
if err := s.Broadcast(userJoinedEvent.GetBroadcastPayload()); err != nil { if err := s.Broadcast(userJoinedEvent.GetBroadcastPayload()); err != nil {
log.Errorln("error adding client to chat server", err) log.Errorln("error adding client to chat server", err)
@ -317,6 +318,7 @@ func (s *Server) sendSystemMessageToClient(c *Client, message string) {
}, },
} }
clientMessage.SetDefaults() clientMessage.SetDefaults()
clientMessage.RenderBody()
s.Send(clientMessage.GetBroadcastPayload(), c) s.Send(clientMessage.GetBroadcastPayload(), c)
} }

View File

@ -12,6 +12,7 @@ func SendChatEvent(chatEvent *events.UserMessageEvent) {
EventData: &WebhookChatMessage{ EventData: &WebhookChatMessage{
User: chatEvent.User, User: chatEvent.User,
Body: chatEvent.Body, Body: chatEvent.Body,
ClientID: chatEvent.ClientID,
RawBody: chatEvent.RawBody, RawBody: chatEvent.RawBody,
ID: chatEvent.ID, ID: chatEvent.ID,
Visible: chatEvent.HiddenAt == nil, Visible: chatEvent.HiddenAt == nil,

View File

@ -23,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 {
User *user.User `json:"user,omitempty"` User *user.User `json:"user,omitempty"`
ClientID uint `json:"clientId,omitempty"`
Body string `json:"body,omitempty"` Body string `json:"body,omitempty"`
RawBody string `json:"rawBody,omitempty"` RawBody string `json:"rawBody,omitempty"`
ID string `json:"id,omitempty"` ID string `json:"id,omitempty"`

View File

@ -1398,6 +1398,58 @@ paths:
type: string type: string
example: sent 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: /api/admin/accesstokens/create:
post: post:

View File

@ -13,6 +13,7 @@ import (
"github.com/owncast/owncast/core/chat" "github.com/owncast/owncast/core/chat"
"github.com/owncast/owncast/core/user" "github.com/owncast/owncast/core/user"
"github.com/owncast/owncast/router/middleware" "github.com/owncast/owncast/router/middleware"
"github.com/owncast/owncast/utils"
"github.com/owncast/owncast/yp" "github.com/owncast/owncast/yp"
) )
@ -160,6 +161,9 @@ func Start() error {
// Send a system message to chat // Send a system message to chat
http.HandleFunc("/api/integrations/chat/system", middleware.RequireExternalAPIAccessToken(user.ScopeCanSendSystemMessages, admin.SendSystemMessage)) 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 // Send a user message to chat *NO LONGER SUPPORTED
http.HandleFunc("/api/integrations/chat/user", middleware.RequireExternalAPIAccessToken(user.ScopeCanSendChatMessages, admin.SendUserMessage)) http.HandleFunc("/api/integrations/chat/user", middleware.RequireExternalAPIAccessToken(user.ScopeCanSendChatMessages, admin.SendUserMessage))

View File

@ -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)
}
}

View File

@ -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)
}
}
}