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:
parent
608888317b
commit
9c484efd36
@ -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")
|
||||||
|
@ -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())
|
||||||
|
@ -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() {
|
||||||
|
@ -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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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,
|
||||||
|
@ -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"`
|
||||||
|
52
openapi.yaml
52
openapi.yaml
@ -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:
|
||||||
|
@ -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))
|
||||||
|
|
||||||
|
69
utils/restendpointhelper.go
Normal file
69
utils/restendpointhelper.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
39
utils/restendpointhelper_test.go
Normal file
39
utils/restendpointhelper_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user