diff --git a/api4/user.go b/api4/user.go index f4f17f4d0b..631599958e 100644 --- a/api4/user.go +++ b/api4/user.go @@ -80,6 +80,8 @@ func (api *API) InitUser() { api.BaseRoutes.Users.Handle("/tokens/revoke", api.ApiSessionRequired(revokeUserAccessToken)).Methods("POST") api.BaseRoutes.Users.Handle("/tokens/disable", api.ApiSessionRequired(disableUserAccessToken)).Methods("POST") api.BaseRoutes.Users.Handle("/tokens/enable", api.ApiSessionRequired(enableUserAccessToken)).Methods("POST") + + api.BaseRoutes.User.Handle("/typing", api.ApiSessionRequiredDisableWhenBusy(publishUserTyping)).Methods("POST") } func createUser(c *Context, w http.ResponseWriter, r *http.Request) { @@ -2270,3 +2272,33 @@ func demoteUserToGuest(c *Context, w http.ResponseWriter, r *http.Request) { auditRec.Success() ReturnStatusOK(w) } + +func publishUserTyping(c *Context, w http.ResponseWriter, r *http.Request) { + c.RequireUserId() + if c.Err != nil { + return + } + + typingRequest := model.TypingRequestFromJson(r.Body) + if typingRequest == nil { + c.SetInvalidParam("typing_request") + return + } + + if c.Params.UserId != c.App.Session().UserId && !c.App.SessionHasPermissionTo(*c.App.Session(), model.PERMISSION_MANAGE_SYSTEM) { + c.SetPermissionError(model.PERMISSION_MANAGE_SYSTEM) + return + } + + if !c.App.HasPermissionToChannel(c.Params.UserId, typingRequest.ChannelId, model.PERMISSION_CREATE_POST) { + c.SetPermissionError(model.PERMISSION_CREATE_POST) + return + } + + if err := c.App.PublishUserTyping(c.Params.UserId, typingRequest.ChannelId, typingRequest.ParentId); err != nil { + c.Err = err + return + } + + ReturnStatusOK(w) +} diff --git a/api4/user_test.go b/api4/user_test.go index 493788a1aa..b1532ca589 100644 --- a/api4/user_test.go +++ b/api4/user_test.go @@ -4781,3 +4781,78 @@ func TestGetKnownUsers(t *testing.T) { assert.ElementsMatch(t, userIds, []string{u2.Id, u3.Id}) }) } + +func TestPublishUserTyping(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + tr := model.TypingRequest{ + ChannelId: th.BasicChannel.Id, + ParentId: "randomparentid", + } + + t.Run("should return ok for non-system admin when triggering typing event for own user", func(t *testing.T) { + _, resp := th.Client.PublishUserTyping(th.BasicUser.Id, tr) + CheckNoError(t, resp) + }) + + t.Run("should return ok for system admin when triggering typing event for own user", func(t *testing.T) { + th.LinkUserToTeam(th.SystemAdminUser, th.BasicTeam) + th.AddUserToChannel(th.SystemAdminUser, th.BasicChannel) + + _, resp := th.SystemAdminClient.PublishUserTyping(th.SystemAdminUser.Id, tr) + CheckNoError(t, resp) + }) + + t.Run("should return forbidden for non-system admin when triggering a typing event for a different user", func(t *testing.T) { + _, resp := th.Client.PublishUserTyping(th.BasicUser2.Id, tr) + CheckForbiddenStatus(t, resp) + }) + + t.Run("should return bad request when triggering a typing event for an invalid user id", func(t *testing.T) { + _, resp := th.Client.PublishUserTyping("invalid", tr) + CheckErrorMessage(t, resp, "api.context.invalid_url_param.app_error") + CheckBadRequestStatus(t, resp) + }) + + t.Run("should send typing event via websocket when triggering a typing event for a user with a common channel", func(t *testing.T) { + webSocketClient, err := th.CreateWebSocketClient() + assert.Nil(t, err) + defer webSocketClient.Close() + + webSocketClient.Listen() + + time.Sleep(300 * time.Millisecond) + wsResp := <-webSocketClient.ResponseChannel + require.Equal(t, model.STATUS_OK, wsResp.Status) + + _, resp := th.SystemAdminClient.PublishUserTyping(th.BasicUser2.Id, tr) + CheckNoError(t, resp) + + assertExpectedWebsocketEvent(t, webSocketClient, model.WEBSOCKET_EVENT_TYPING, func(resp *model.WebSocketEvent) { + assert.Equal(t, th.BasicChannel.Id, resp.GetBroadcast().ChannelId) + + eventUserId, ok := resp.GetData()["user_id"].(string) + require.True(t, ok, "expected user_id") + assert.Equal(t, th.BasicUser2.Id, eventUserId) + + eventParentId, ok := resp.GetData()["parent_id"].(string) + require.True(t, ok, "expected parent_id") + assert.Equal(t, "randomparentid", eventParentId) + }) + }) + + th.Server.Busy.Set(time.Second * 10) + + t.Run("should return service unavailable for non-system admin user when triggering a typing event and server busy", func(t *testing.T) { + _, resp := th.Client.PublishUserTyping("invalid", tr) + CheckErrorMessage(t, resp, "api.context.server_busy.app_error") + CheckServiceUnavailableStatus(t, resp) + }) + + t.Run("should return service unavailable for system admin user when triggering a typing event and server busy", func(t *testing.T) { + _, resp := th.SystemAdminClient.PublishUserTyping(th.SystemAdminUser.Id, tr) + CheckErrorMessage(t, resp, "api.context.server_busy.app_error") + CheckServiceUnavailableStatus(t, resp) + }) +} diff --git a/app/app_iface.go b/app/app_iface.go index 471307b8aa..9ec6674538 100644 --- a/app/app_iface.go +++ b/app/app_iface.go @@ -774,6 +774,7 @@ type AppIface interface { ProcessSlackText(text string) string Publish(message *model.WebSocketEvent) PublishSkipClusterSend(message *model.WebSocketEvent) + PublishUserTyping(userId, channelId, parentId string) *model.AppError PurgeBleveIndexes() *model.AppError PurgeElasticsearchIndexes() *model.AppError ReadFile(path string) ([]byte, *model.AppError) diff --git a/app/opentracing_layer.go b/app/opentracing_layer.go index 4a42971efb..e287386341 100644 --- a/app/opentracing_layer.go +++ b/app/opentracing_layer.go @@ -10750,6 +10750,28 @@ func (a *OpenTracingAppLayer) PublishSkipClusterSend(message *model.WebSocketEve a.app.PublishSkipClusterSend(message) } +func (a *OpenTracingAppLayer) PublishUserTyping(userId string, channelId string, parentId string) *model.AppError { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.PublishUserTyping") + + a.ctx = newCtx + a.app.Srv().Store.SetContext(newCtx) + defer func() { + a.app.Srv().Store.SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0 := a.app.PublishUserTyping(userId, channelId, parentId) + + if resultVar0 != nil { + span.LogFields(spanlog.Error(resultVar0)) + ext.Error.Set(span, true) + } + + return resultVar0 +} + func (a *OpenTracingAppLayer) PurgeBleveIndexes() *model.AppError { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.PurgeBleveIndexes") diff --git a/app/plugin_api.go b/app/plugin_api.go index 47653c4723..313f58f729 100644 --- a/app/plugin_api.go +++ b/app/plugin_api.go @@ -850,6 +850,10 @@ func (api *PluginAPI) DeleteBotIconImage(userId string) *model.AppError { return api.app.DeleteBotIconImage(userId) } +func (api *PluginAPI) PublishUserTyping(userId, channelId, parentId string) *model.AppError { + return api.app.PublishUserTyping(userId, channelId, parentId) +} + func (api *PluginAPI) PluginHTTP(request *http.Request) *http.Response { split := strings.SplitN(request.URL.Path, "/", 3) if len(split) != 3 { diff --git a/app/user.go b/app/user.go index 3cbb109bcf..5ab3744b5b 100644 --- a/app/user.go +++ b/app/user.go @@ -2088,6 +2088,18 @@ func (a *App) DemoteUserToGuest(user *model.User) *model.AppError { return nil } +func (a *App) PublishUserTyping(userId, channelId, parentId string) *model.AppError { + omitUsers := make(map[string]bool, 1) + omitUsers[userId] = true + + event := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_TYPING, "", channelId, "", omitUsers) + event.Add("parent_id", parentId) + event.Add("user_id", userId) + a.Publish(event) + + return nil +} + // invalidateUserCacheAndPublish Invalidates cache for a user and publishes user updated event func (a *App) invalidateUserCacheAndPublish(userId string) { a.InvalidateCacheForUser(userId) diff --git a/model/client4.go b/model/client4.go index 8a8d55fd9b..ee665c9fbc 100644 --- a/model/client4.go +++ b/model/client4.go @@ -487,6 +487,10 @@ func (c *Client4) GetGroupsRoute() string { return "/groups" } +func (c *Client4) GetPublishUserTypingRoute(userId string) string { + return c.GetUserRoute(userId) + "/typing" +} + func (c *Client4) GetGroupRoute(groupID string) string { return fmt.Sprintf("%s/%s", c.GetGroupsRoute(), groupID) } @@ -5094,6 +5098,16 @@ func (c *Client4) GetKnownUsers() ([]string, *Response) { return userIds, BuildResponse(r) } +// PublishUserTyping publishes a user is typing websocket event based on the provided TypingRequest. +func (c *Client4) PublishUserTyping(userID string, typingRequest TypingRequest) (bool, *Response) { + r, err := c.DoApiPost(c.GetPublishUserTypingRoute(userID), typingRequest.ToJson()) + if err != nil { + return false, BuildErrorResponse(r, err) + } + defer closeBody(r) + return CheckStatusOK(r), BuildResponse(r) +} + func (c *Client4) GetChannelMemberCountsByGroup(channelID string, includeTimezones bool, etag string) ([]*ChannelMemberCountByGroup, *Response) { r, err := c.DoApiGet(c.GetChannelRoute(channelID)+"/member_counts_by_group?include_timezones="+strconv.FormatBool(includeTimezones), etag) if err != nil { diff --git a/model/typing_request.go b/model/typing_request.go new file mode 100644 index 0000000000..e2e9d3bfba --- /dev/null +++ b/model/typing_request.go @@ -0,0 +1,25 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package model + +import ( + "encoding/json" + "io" +) + +type TypingRequest struct { + ChannelId string `json:"channel_id"` + ParentId string `json:"parent_id"` +} + +func (o *TypingRequest) ToJson() string { + b, _ := json.Marshal(o) + return string(b) +} + +func TypingRequestFromJson(data io.Reader) *TypingRequest { + var o *TypingRequest + json.NewDecoder(data).Decode(&o) + return o +} diff --git a/model/typing_request_test.go b/model/typing_request_test.go new file mode 100644 index 0000000000..0e373744ca --- /dev/null +++ b/model/typing_request_test.go @@ -0,0 +1,20 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package model + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestTypingRequestJson(t *testing.T) { + o := TypingRequest{ChannelId: NewId(), ParentId: NewId()} + json := o.ToJson() + ro := TypingRequestFromJson(strings.NewReader(json)) + + require.Equal(t, o.ChannelId, ro.ChannelId, "ChannelIds do not match") + require.Equal(t, o.ParentId, ro.ParentId, "ParentIds do not match") +} diff --git a/plugin/api.go b/plugin/api.go index 2fec5ab05b..0e3b49b130 100644 --- a/plugin/api.go +++ b/plugin/api.go @@ -953,6 +953,13 @@ type API interface { // // Minimum server version: 5.18 PluginHTTP(request *http.Request) *http.Response + + // PublishUserTyping publishes a user is typing WebSocket event. + // The parentId parameter may be an empty string, the other parameters are required. + // + // @tag User + // Minimum server version: 5.26 + PublishUserTyping(userId, channelId, parentId string) *model.AppError } var handshake = plugin.HandshakeConfig{ diff --git a/plugin/api_timer_layer_generated.go b/plugin/api_timer_layer_generated.go index 3b8f69a983..5c8ec47ead 100644 --- a/plugin/api_timer_layer_generated.go +++ b/plugin/api_timer_layer_generated.go @@ -1015,3 +1015,10 @@ func (api *apiTimerLayer) PluginHTTP(request *http.Request) *http.Response { api.recordTime(startTime, "PluginHTTP", true) return _returnsA } + +func (api *apiTimerLayer) PublishUserTyping(userId, channelId, parentId string) *model.AppError { + startTime := timePkg.Now() + _returnsA := api.apiImpl.PublishUserTyping(userId, channelId, parentId) + api.recordTime(startTime, "PublishUserTyping", true) + return _returnsA +} diff --git a/plugin/client_rpc_generated.go b/plugin/client_rpc_generated.go index 757ba0d637..5b07acec86 100644 --- a/plugin/client_rpc_generated.go +++ b/plugin/client_rpc_generated.go @@ -4422,3 +4422,33 @@ func (s *apiRPCServer) DeleteBotIconImage(args *Z_DeleteBotIconImageArgs, return } return nil } + +type Z_PublishUserTypingArgs struct { + A string + B string + C string +} + +type Z_PublishUserTypingReturns struct { + A *model.AppError +} + +func (g *apiRPCClient) PublishUserTyping(userId, channelId, parentId string) *model.AppError { + _args := &Z_PublishUserTypingArgs{userId, channelId, parentId} + _returns := &Z_PublishUserTypingReturns{} + if err := g.client.Call("Plugin.PublishUserTyping", _args, _returns); err != nil { + log.Printf("RPC call to PublishUserTyping API failed: %s", err.Error()) + } + return _returns.A +} + +func (s *apiRPCServer) PublishUserTyping(args *Z_PublishUserTypingArgs, returns *Z_PublishUserTypingReturns) error { + if hook, ok := s.impl.(interface { + PublishUserTyping(userId, channelId, parentId string) *model.AppError + }); ok { + returns.A = hook.PublishUserTyping(args.A, args.B, args.C) + } else { + return encodableError(fmt.Errorf("API PublishUserTyping called but not implemented.")) + } + return nil +} diff --git a/plugin/plugintest/api.go b/plugin/plugintest/api.go index 6a8abc13b2..d9855a942d 100644 --- a/plugin/plugintest/api.go +++ b/plugin/plugintest/api.go @@ -2443,6 +2443,22 @@ func (_m *API) PluginHTTP(request *http.Request) *http.Response { return r0 } +// PublishUserTyping provides a mock function with given fields: userId, channelId, parentId +func (_m *API) PublishUserTyping(userId string, channelId string, parentId string) *model.AppError { + ret := _m.Called(userId, channelId, parentId) + + var r0 *model.AppError + if rf, ok := ret.Get(0).(func(string, string, string) *model.AppError); ok { + r0 = rf(userId, channelId, parentId) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.AppError) + } + } + + return r0 +} + // PublishWebSocketEvent provides a mock function with given fields: event, payload, broadcast func (_m *API) PublishWebSocketEvent(event string, payload map[string]interface{}, broadcast *model.WebsocketBroadcast) { _m.Called(event, payload, broadcast) diff --git a/wsapi/user.go b/wsapi/user.go index c317ae09fe..b4effc9f50 100644 --- a/wsapi/user.go +++ b/wsapi/user.go @@ -35,15 +35,9 @@ func (api *API) userTyping(req *model.WebSocketRequest) (map[string]interface{}, parentId = "" } - omitUsers := make(map[string]bool, 1) - omitUsers[req.Session.UserId] = true + appErr := api.App.PublishUserTyping(req.Session.UserId, channelId, parentId) - event := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_TYPING, "", channelId, "", omitUsers) - event.Add("parent_id", parentId) - event.Add("user_id", req.Session.UserId) - api.App.Publish(event) - - return nil, nil + return nil, appErr } func (api *API) userUpdateActiveStatus(req *model.WebSocketRequest) (map[string]interface{}, *model.AppError) {