Added infrastructure for basic WebSocket API (#3432)
Этот коммит содержится в:
коммит произвёл
GitHub
родитель
06eacf30b9
Коммит
ad343a0f4a
@@ -48,6 +48,8 @@ type Routes struct {
|
||||
Public *mux.Router // 'api/v3/public'
|
||||
|
||||
Emoji *mux.Router // 'api/v3/emoji'
|
||||
|
||||
WebSocket *WebSocketRouter // websocket api
|
||||
}
|
||||
|
||||
var BaseRoutes *Routes
|
||||
@@ -76,6 +78,8 @@ func InitApi() {
|
||||
BaseRoutes.Public = BaseRoutes.ApiRoot.PathPrefix("/public").Subrouter()
|
||||
BaseRoutes.Emoji = BaseRoutes.ApiRoot.PathPrefix("/emoji").Subrouter()
|
||||
|
||||
BaseRoutes.WebSocket = NewWebSocketRouter()
|
||||
|
||||
InitUser()
|
||||
InitTeam()
|
||||
InitChannel()
|
||||
|
||||
@@ -103,6 +103,10 @@ func (me *TestHelper) CreateClient() *model.Client {
|
||||
return model.NewClient("http://localhost" + utils.Cfg.ServiceSettings.ListenAddress)
|
||||
}
|
||||
|
||||
func (me *TestHelper) CreateWebSocketClient() (*model.WebSocketClient, *model.AppError) {
|
||||
return model.NewWebSocketClient("ws://localhost"+utils.Cfg.ServiceSettings.ListenAddress, me.BasicClient.AuthToken)
|
||||
}
|
||||
|
||||
func (me *TestHelper) CreateTeam(client *model.Client) *model.Team {
|
||||
id := model.NewId()
|
||||
team := &model.Team{
|
||||
|
||||
@@ -158,7 +158,7 @@ func CreateDirectChannel(userId string, otherUserId string) (*model.Channel, *mo
|
||||
return nil, result.Err
|
||||
}
|
||||
} else {
|
||||
message := model.NewMessage("", channel.Id, userId, model.ACTION_DIRECT_ADDED)
|
||||
message := model.NewWebSocketEvent("", channel.Id, userId, model.WEBSOCKET_EVENT_DIRECT_ADDED)
|
||||
message.Add("teammate_id", otherUserId)
|
||||
go Publish(message)
|
||||
|
||||
@@ -587,7 +587,7 @@ func AddUserToChannel(user *model.User, channel *model.Channel) (*model.ChannelM
|
||||
go func() {
|
||||
InvalidateCacheForUser(user.Id)
|
||||
|
||||
message := model.NewMessage(channel.TeamId, channel.Id, user.Id, model.ACTION_USER_ADDED)
|
||||
message := model.NewWebSocketEvent(channel.TeamId, channel.Id, user.Id, model.WEBSOCKET_EVENT_USER_ADDED)
|
||||
go Publish(message)
|
||||
}()
|
||||
|
||||
@@ -772,7 +772,7 @@ func deleteChannel(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
go func() {
|
||||
InvalidateCacheForChannel(channel.Id)
|
||||
message := model.NewMessage(c.TeamId, channel.Id, c.Session.UserId, model.ACTION_CHANNEL_DELETED)
|
||||
message := model.NewWebSocketEvent(c.TeamId, channel.Id, c.Session.UserId, model.WEBSOCKET_EVENT_CHANNEL_DELETED)
|
||||
go Publish(message)
|
||||
|
||||
post := &model.Post{
|
||||
@@ -806,7 +806,7 @@ func updateLastViewedAt(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
Srv.Store.Preference().Save(&model.Preferences{preference})
|
||||
|
||||
message := model.NewMessage(c.TeamId, id, c.Session.UserId, model.ACTION_CHANNEL_VIEWED)
|
||||
message := model.NewWebSocketEvent(c.TeamId, id, c.Session.UserId, model.WEBSOCKET_EVENT_CHANNEL_VIEWED)
|
||||
message.Add("channel_id", id)
|
||||
|
||||
go Publish(message)
|
||||
@@ -1032,7 +1032,7 @@ func RemoveUserFromChannel(userIdToRemove string, removerUserId string, channel
|
||||
|
||||
InvalidateCacheForUser(userIdToRemove)
|
||||
|
||||
message := model.NewMessage(channel.TeamId, channel.Id, userIdToRemove, model.ACTION_USER_REMOVED)
|
||||
message := model.NewWebSocketEvent(channel.TeamId, channel.Id, userIdToRemove, model.WEBSOCKET_EVENT_USER_REMOVED)
|
||||
message.Add("remover_id", removerUserId)
|
||||
go Publish(message)
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ func setCollapsePreference(c *Context, value string) *model.CommandResponse {
|
||||
return &model.CommandResponse{Text: c.T("api.command_expand_collapse.fail.app_error"), ResponseType: model.COMMAND_RESPONSE_TYPE_EPHEMERAL}
|
||||
}
|
||||
|
||||
socketMessage := model.NewMessage("", "", c.Session.UserId, model.ACTION_PREFERENCE_CHANGED)
|
||||
socketMessage := model.NewWebSocketEvent("", "", c.Session.UserId, model.WEBSOCKET_EVENT_PREFERENCE_CHANGED)
|
||||
socketMessage.Add("preference", pref.ToJson())
|
||||
go Publish(socketMessage)
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ func InitGeneral() {
|
||||
BaseRoutes.General.Handle("/log_client", ApiAppHandler(logClient)).Methods("POST")
|
||||
BaseRoutes.General.Handle("/ping", ApiAppHandler(ping)).Methods("GET")
|
||||
|
||||
BaseRoutes.WebSocket.Handle("ping", ApiWebSocketHandler(webSocketPing))
|
||||
}
|
||||
|
||||
func getClientConfig(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
@@ -71,3 +72,8 @@ func ping(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
m["node_id"] = ""
|
||||
w.Write([]byte(model.MapToJson(m)))
|
||||
}
|
||||
|
||||
func webSocketPing(req *model.WebSocketRequest, responseData map[string]interface{}) *model.AppError {
|
||||
responseData["text"] = "pong"
|
||||
return nil
|
||||
}
|
||||
|
||||
12
api/post.go
12
api/post.go
@@ -329,7 +329,7 @@ func makeDirectChannelVisible(teamId string, channelId string) {
|
||||
if saveResult := <-Srv.Store.Preference().Save(&model.Preferences{*preference}); saveResult.Err != nil {
|
||||
l4g.Error(utils.T("api.post.make_direct_channel_visible.save_pref.error"), member.UserId, otherUserId, saveResult.Err.Message)
|
||||
} else {
|
||||
message := model.NewMessage(teamId, channelId, member.UserId, model.ACTION_PREFERENCE_CHANGED)
|
||||
message := model.NewWebSocketEvent(teamId, channelId, member.UserId, model.WEBSOCKET_EVENT_PREFERENCE_CHANGED)
|
||||
message.Add("preference", preference.ToJson())
|
||||
|
||||
go Publish(message)
|
||||
@@ -344,7 +344,7 @@ func makeDirectChannelVisible(teamId string, channelId string) {
|
||||
if updateResult := <-Srv.Store.Preference().Save(&model.Preferences{preference}); updateResult.Err != nil {
|
||||
l4g.Error(utils.T("api.post.make_direct_channel_visible.update_pref.error"), member.UserId, otherUserId, updateResult.Err.Message)
|
||||
} else {
|
||||
message := model.NewMessage(teamId, channelId, member.UserId, model.ACTION_PREFERENCE_CHANGED)
|
||||
message := model.NewWebSocketEvent(teamId, channelId, member.UserId, model.WEBSOCKET_EVENT_PREFERENCE_CHANGED)
|
||||
message.Add("preference", preference.ToJson())
|
||||
|
||||
go Publish(message)
|
||||
@@ -627,7 +627,7 @@ func sendNotifications(c *Context, post *model.Post, team *model.Team, channel *
|
||||
}
|
||||
}
|
||||
|
||||
message := model.NewMessage(c.TeamId, post.ChannelId, post.UserId, model.ACTION_POSTED)
|
||||
message := model.NewWebSocketEvent(c.TeamId, post.ChannelId, post.UserId, model.WEBSOCKET_EVENT_POSTED)
|
||||
message.Add("post", post.ToJson())
|
||||
message.Add("channel_type", channel.Type)
|
||||
message.Add("channel_display_name", channel.DisplayName)
|
||||
@@ -905,7 +905,7 @@ func SendEphemeralPost(teamId, userId string, post *model.Post) {
|
||||
post.Filenames = []string{}
|
||||
}
|
||||
|
||||
message := model.NewMessage(teamId, post.ChannelId, userId, model.ACTION_EPHEMERAL_MESSAGE)
|
||||
message := model.NewWebSocketEvent(teamId, post.ChannelId, userId, model.WEBSOCKET_EVENT_EPHEMERAL_MESSAGE)
|
||||
message.Add("post", post.ToJson())
|
||||
|
||||
go Publish(message)
|
||||
@@ -967,7 +967,7 @@ func updatePost(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
} else {
|
||||
rpost := result.Data.(*model.Post)
|
||||
|
||||
message := model.NewMessage(c.TeamId, rpost.ChannelId, c.Session.UserId, model.ACTION_POST_EDITED)
|
||||
message := model.NewWebSocketEvent(c.TeamId, rpost.ChannelId, c.Session.UserId, model.WEBSOCKET_EVENT_POST_EDITED)
|
||||
message.Add("post", rpost.ToJson())
|
||||
|
||||
go Publish(message)
|
||||
@@ -1231,7 +1231,7 @@ func deletePost(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
message := model.NewMessage(c.TeamId, post.ChannelId, c.Session.UserId, model.ACTION_POST_DELETED)
|
||||
message := model.NewWebSocketEvent(c.TeamId, post.ChannelId, c.Session.UserId, model.WEBSOCKET_EVENT_POST_DELETED)
|
||||
message.Add("post", post.ToJson())
|
||||
|
||||
go Publish(message)
|
||||
|
||||
@@ -298,7 +298,7 @@ func JoinUserToTeam(team *model.Team, user *model.User) *model.AppError {
|
||||
InvalidateCacheForUser(user.Id)
|
||||
|
||||
// This message goes to every channel, so the channelId is irrelevant
|
||||
go Publish(model.NewMessage("", "", user.Id, model.ACTION_NEW_USER))
|
||||
go Publish(model.NewWebSocketEvent("", "", user.Id, model.WEBSOCKET_EVENT_NEW_USER))
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -348,7 +348,7 @@ func LeaveTeam(team *model.Team, user *model.User) *model.AppError {
|
||||
RemoveAllSessionsForUserId(user.Id)
|
||||
InvalidateCacheForUser(user.Id)
|
||||
|
||||
go Publish(model.NewMessage(team.Id, "", user.Id, model.ACTION_LEAVE_TEAM))
|
||||
go Publish(model.NewWebSocketEvent(team.Id, "", user.Id, model.WEBSOCKET_EVENT_LEAVE_TEAM))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
23
api/user.go
23
api/user.go
@@ -75,6 +75,8 @@ func InitUser() {
|
||||
|
||||
BaseRoutes.Root.Handle("/login/sso/saml", AppHandlerIndependent(loginWithSaml)).Methods("GET")
|
||||
BaseRoutes.Root.Handle("/login/sso/saml", AppHandlerIndependent(completeSaml)).Methods("POST")
|
||||
|
||||
BaseRoutes.WebSocket.Handle("user_typing", ApiWebSocketHandler(userTyping))
|
||||
}
|
||||
|
||||
func createUser(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
@@ -269,7 +271,7 @@ func CreateUser(user *model.User) (*model.User, *model.AppError) {
|
||||
ruser.Sanitize(map[string]bool{})
|
||||
|
||||
// This message goes to every channel, so the channelId is irrelevant
|
||||
go Publish(model.NewMessage("", "", ruser.Id, model.ACTION_NEW_USER))
|
||||
go Publish(model.NewWebSocketEvent("", "", ruser.Id, model.WEBSOCKET_EVENT_NEW_USER))
|
||||
|
||||
return ruser, nil
|
||||
}
|
||||
@@ -2540,3 +2542,22 @@ func completeSaml(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, GetProtocol(r)+"://"+r.Host, http.StatusFound)
|
||||
}
|
||||
}
|
||||
|
||||
func userTyping(req *model.WebSocketRequest, responseData map[string]interface{}) *model.AppError {
|
||||
var ok bool
|
||||
var channelId string
|
||||
if channelId, ok = req.Data["channel_id"].(string); !ok || len(channelId) != 26 {
|
||||
return NewInvalidWebSocketParamError(req.Action, "channel_id")
|
||||
}
|
||||
|
||||
var parentId string
|
||||
if parentId, ok = req.Data["parent_id"].(string); !ok {
|
||||
parentId = ""
|
||||
}
|
||||
|
||||
event := model.NewWebSocketEvent("", channelId, req.Session.UserId, model.WEBSOCKET_EVENT_TYPING)
|
||||
event.Add("parent_id", parentId)
|
||||
go Publish(event)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1719,3 +1719,85 @@ func TestCheckMfa(t *testing.T) {
|
||||
|
||||
// need to add more test cases when enterprise bits can be loaded into tests
|
||||
}
|
||||
|
||||
func TestUserTyping(t *testing.T) {
|
||||
th := Setup().InitBasic()
|
||||
Client := th.BasicClient
|
||||
WebSocketClient, err := th.CreateWebSocketClient()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer WebSocketClient.Close()
|
||||
WebSocketClient.Listen()
|
||||
|
||||
WebSocketClient.UserTyping("", "")
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
if resp := <-WebSocketClient.ResponseChannel; resp.Error.Id != "api.websocket_handler.invalid_param.app_error" {
|
||||
t.Fatal("should have been invalid param response")
|
||||
}
|
||||
|
||||
th.LoginBasic2()
|
||||
Client.Must(Client.JoinChannel(th.BasicChannel.Id))
|
||||
|
||||
WebSocketClient2, err2 := th.CreateWebSocketClient()
|
||||
if err2 != nil {
|
||||
t.Fatal(err2)
|
||||
}
|
||||
defer WebSocketClient2.Close()
|
||||
WebSocketClient2.Listen()
|
||||
|
||||
WebSocketClient.UserTyping(th.BasicChannel.Id, "")
|
||||
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
|
||||
stop := make(chan bool)
|
||||
eventHit := false
|
||||
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case resp := <-WebSocketClient2.EventChannel:
|
||||
if resp.Event == model.WEBSOCKET_EVENT_TYPING && resp.UserId == th.BasicUser.Id {
|
||||
eventHit = true
|
||||
}
|
||||
case <-stop:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
|
||||
stop <- true
|
||||
|
||||
if !eventHit {
|
||||
t.Fatal("did not receive typing event")
|
||||
}
|
||||
|
||||
WebSocketClient.UserTyping(th.BasicChannel.Id, "someparentid")
|
||||
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
|
||||
eventHit = false
|
||||
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case resp := <-WebSocketClient2.EventChannel:
|
||||
if resp.Event == model.WEBSOCKET_EVENT_TYPING && resp.Data["parent_id"] == "someparentid" {
|
||||
eventHit = true
|
||||
}
|
||||
case <-stop:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
|
||||
stop <- true
|
||||
|
||||
if !eventHit {
|
||||
t.Fatal("did not receive typing event")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,10 +6,12 @@ package api
|
||||
import (
|
||||
"time"
|
||||
|
||||
l4g "github.com/alecthomas/log4go"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/mattermost/platform/model"
|
||||
"github.com/mattermost/platform/utils"
|
||||
|
||||
l4g "github.com/alecthomas/log4go"
|
||||
"github.com/gorilla/websocket"
|
||||
goi18n "github.com/nicksnyder/go-i18n/i18n"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -22,32 +24,36 @@ const (
|
||||
|
||||
type WebConn struct {
|
||||
WebSocket *websocket.Conn
|
||||
Send chan *model.Message
|
||||
Send chan model.WebSocketMessage
|
||||
SessionToken string
|
||||
UserId string
|
||||
T goi18n.TranslateFunc
|
||||
Locale string
|
||||
hasPermissionsToChannel map[string]bool
|
||||
hasPermissionsToTeam map[string]bool
|
||||
}
|
||||
|
||||
func NewWebConn(ws *websocket.Conn, userId string, sessionToken string) *WebConn {
|
||||
func NewWebConn(c *Context, ws *websocket.Conn) *WebConn {
|
||||
go func() {
|
||||
achan := Srv.Store.User().UpdateUserAndSessionActivity(userId, sessionToken, model.GetMillis())
|
||||
pchan := Srv.Store.User().UpdateLastPingAt(userId, model.GetMillis())
|
||||
achan := Srv.Store.User().UpdateUserAndSessionActivity(c.Session.UserId, c.Session.Token, model.GetMillis())
|
||||
pchan := Srv.Store.User().UpdateLastPingAt(c.Session.UserId, model.GetMillis())
|
||||
|
||||
if result := <-achan; result.Err != nil {
|
||||
l4g.Error(utils.T("api.web_conn.new_web_conn.last_activity.error"), userId, sessionToken, result.Err)
|
||||
l4g.Error(utils.T("api.web_conn.new_web_conn.last_activity.error"), c.Session.UserId, c.Session.Token, result.Err)
|
||||
}
|
||||
|
||||
if result := <-pchan; result.Err != nil {
|
||||
l4g.Error(utils.T("api.web_conn.new_web_conn.last_ping.error"), userId, result.Err)
|
||||
l4g.Error(utils.T("api.web_conn.new_web_conn.last_ping.error"), c.Session.UserId, result.Err)
|
||||
}
|
||||
}()
|
||||
|
||||
return &WebConn{
|
||||
Send: make(chan *model.Message, 64),
|
||||
Send: make(chan model.WebSocketMessage, 64),
|
||||
WebSocket: ws,
|
||||
UserId: userId,
|
||||
SessionToken: sessionToken,
|
||||
UserId: c.Session.UserId,
|
||||
SessionToken: c.Session.Token,
|
||||
T: c.T,
|
||||
Locale: c.Locale,
|
||||
hasPermissionsToChannel: make(map[string]bool),
|
||||
hasPermissionsToTeam: make(map[string]bool),
|
||||
}
|
||||
@@ -73,12 +79,11 @@ func (c *WebConn) readPump() {
|
||||
})
|
||||
|
||||
for {
|
||||
var msg model.Message
|
||||
if err := c.WebSocket.ReadJSON(&msg); err != nil {
|
||||
var req model.WebSocketRequest
|
||||
if err := c.WebSocket.ReadJSON(&req); err != nil {
|
||||
return
|
||||
} else {
|
||||
msg.UserId = c.UserId
|
||||
go Publish(&msg)
|
||||
BaseRoutes.WebSocket.ServeWebSocket(c, &req)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ type Hub struct {
|
||||
connections map[*WebConn]bool
|
||||
register chan *WebConn
|
||||
unregister chan *WebConn
|
||||
broadcast chan *model.Message
|
||||
broadcast chan *model.WebSocketEvent
|
||||
stop chan string
|
||||
invalidateUser chan string
|
||||
invalidateChannel chan string
|
||||
@@ -23,13 +23,13 @@ var hub = &Hub{
|
||||
register: make(chan *WebConn),
|
||||
unregister: make(chan *WebConn),
|
||||
connections: make(map[*WebConn]bool),
|
||||
broadcast: make(chan *model.Message),
|
||||
broadcast: make(chan *model.WebSocketEvent),
|
||||
stop: make(chan string),
|
||||
invalidateUser: make(chan string),
|
||||
invalidateChannel: make(chan string),
|
||||
}
|
||||
|
||||
func Publish(message *model.Message) {
|
||||
func Publish(message *model.WebSocketEvent) {
|
||||
hub.Broadcast(message)
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ func (h *Hub) Unregister(webConn *WebConn) {
|
||||
h.unregister <- webConn
|
||||
}
|
||||
|
||||
func (h *Hub) Broadcast(message *model.Message) {
|
||||
func (h *Hub) Broadcast(message *model.WebSocketEvent) {
|
||||
if message != nil {
|
||||
h.broadcast <- message
|
||||
}
|
||||
@@ -108,11 +108,10 @@ func (h *Hub) Start() {
|
||||
}()
|
||||
}
|
||||
|
||||
func shouldSendEvent(webCon *WebConn, msg *model.Message) bool {
|
||||
|
||||
func shouldSendEvent(webCon *WebConn, msg *model.WebSocketEvent) bool {
|
||||
if webCon.UserId == msg.UserId {
|
||||
// Don't need to tell the user they are typing
|
||||
if msg.Action == model.ACTION_TYPING {
|
||||
if msg.Event == model.WEBSOCKET_EVENT_TYPING {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -127,11 +126,11 @@ func shouldSendEvent(webCon *WebConn, msg *model.Message) bool {
|
||||
}
|
||||
} else {
|
||||
// Don't share a user's view or preference events with other users
|
||||
if msg.Action == model.ACTION_CHANNEL_VIEWED {
|
||||
if msg.Event == model.WEBSOCKET_EVENT_CHANNEL_VIEWED {
|
||||
return false
|
||||
} else if msg.Action == model.ACTION_PREFERENCE_CHANGED {
|
||||
} else if msg.Event == model.WEBSOCKET_EVENT_PREFERENCE_CHANGED {
|
||||
return false
|
||||
} else if msg.Action == model.ACTION_EPHEMERAL_MESSAGE {
|
||||
} else if msg.Event == model.WEBSOCKET_EVENT_EPHEMERAL_MESSAGE {
|
||||
// For now, ephemeral messages are sent directly to individual users
|
||||
return false
|
||||
}
|
||||
@@ -146,7 +145,7 @@ func shouldSendEvent(webCon *WebConn, msg *model.Message) bool {
|
||||
}
|
||||
|
||||
// Only report events to users who are in the channel for the event execept deleted events
|
||||
if len(msg.ChannelId) > 0 && msg.Action != model.ACTION_CHANNEL_DELETED {
|
||||
if len(msg.ChannelId) > 0 && msg.Event != model.WEBSOCKET_EVENT_CHANNEL_DELETED {
|
||||
allowed := webCon.HasPermissionsToChannel(msg.ChannelId)
|
||||
|
||||
if !allowed {
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
|
||||
// See License.txt for license information.
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/mattermost/platform/model"
|
||||
"github.com/mattermost/platform/utils"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSocket(t *testing.T) {
|
||||
th := Setup().InitBasic()
|
||||
Client := th.BasicClient
|
||||
team := th.BasicTeam
|
||||
channel1 := th.BasicChannel
|
||||
channel2 := th.CreateChannel(Client, team)
|
||||
Client.Must(Client.AddChannelMember(channel1.Id, th.BasicUser2.Id))
|
||||
|
||||
url := "ws://localhost" + utils.Cfg.ServiceSettings.ListenAddress + model.API_URL_SUFFIX + "/users/websocket"
|
||||
|
||||
header1 := http.Header{}
|
||||
header1.Set(model.HEADER_AUTH, "BEARER "+Client.AuthToken)
|
||||
|
||||
c1, _, err := websocket.DefaultDialer.Dial(url, header1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
th.LoginBasic2()
|
||||
|
||||
header2 := http.Header{}
|
||||
header2.Set(model.HEADER_AUTH, "BEARER "+Client.AuthToken)
|
||||
|
||||
c2, _, err := websocket.DefaultDialer.Dial(url, header2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
|
||||
var rmsg model.Message
|
||||
|
||||
// Test sending message without a channelId
|
||||
m := model.NewMessage(team.Id, "", "", model.ACTION_TYPING)
|
||||
m.Add("RootId", model.NewId())
|
||||
m.Add("ParentId", model.NewId())
|
||||
|
||||
c1.WriteJSON(m)
|
||||
|
||||
if err := c2.ReadJSON(&rmsg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Log(rmsg.ToJson())
|
||||
|
||||
if team.Id != rmsg.TeamId {
|
||||
t.Fatal("Ids do not match")
|
||||
}
|
||||
|
||||
if m.Props["RootId"] != rmsg.Props["RootId"] {
|
||||
t.Fatal("Ids do not match")
|
||||
}
|
||||
|
||||
// Test sending messsage to Channel you have access to
|
||||
m = model.NewMessage(team.Id, channel1.Id, "", model.ACTION_TYPING)
|
||||
m.Add("RootId", model.NewId())
|
||||
m.Add("ParentId", model.NewId())
|
||||
|
||||
c1.WriteJSON(m)
|
||||
|
||||
if err := c2.ReadJSON(&rmsg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if team.Id != rmsg.TeamId {
|
||||
t.Fatal("Ids do not match")
|
||||
}
|
||||
|
||||
if m.Props["RootId"] != rmsg.Props["RootId"] {
|
||||
t.Fatal("Ids do not match")
|
||||
}
|
||||
|
||||
// Test sending message to Channel you *do not* have access too
|
||||
m = model.NewMessage("", channel2.Id, "", model.ACTION_TYPING)
|
||||
m.Add("RootId", model.NewId())
|
||||
m.Add("ParentId", model.NewId())
|
||||
|
||||
c1.WriteJSON(m)
|
||||
|
||||
go func() {
|
||||
if err := c2.ReadJSON(&rmsg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Fatal(err)
|
||||
}()
|
||||
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"github.com/mattermost/platform/model"
|
||||
"github.com/mattermost/platform/utils"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCreateIncomingHook(t *testing.T) {
|
||||
@@ -629,12 +628,3 @@ func TestIncomingWebhooks(t *testing.T) {
|
||||
t.Fatal("should have failed - webhooks turned off")
|
||||
}
|
||||
}
|
||||
|
||||
func TestZZWebSocketTearDown(t *testing.T) {
|
||||
// *IMPORTANT* - Kind of hacky
|
||||
// This should be the last function in any test file
|
||||
// that calls Setup()
|
||||
// Should be in the last file too sorted by name
|
||||
time.Sleep(2 * time.Second)
|
||||
TearDown()
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ func connect(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
wc := NewWebConn(ws, c.Session.UserId, c.Session.Token)
|
||||
wc := NewWebConn(c, ws)
|
||||
hub.Register(wc)
|
||||
go wc.writePump()
|
||||
wc.readPump()
|
||||
42
api/websocket_handler.go
Обычный файл
42
api/websocket_handler.go
Обычный файл
@@ -0,0 +1,42 @@
|
||||
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
|
||||
// See License.txt for license information.
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
l4g "github.com/alecthomas/log4go"
|
||||
|
||||
"github.com/mattermost/platform/model"
|
||||
"github.com/mattermost/platform/utils"
|
||||
)
|
||||
|
||||
func ApiWebSocketHandler(wh func(*model.WebSocketRequest, map[string]interface{}) *model.AppError) *webSocketHandler {
|
||||
return &webSocketHandler{wh}
|
||||
}
|
||||
|
||||
type webSocketHandler struct {
|
||||
handlerFunc func(*model.WebSocketRequest, map[string]interface{}) *model.AppError
|
||||
}
|
||||
|
||||
func (wh *webSocketHandler) ServeWebSocket(conn *WebConn, r *model.WebSocketRequest) {
|
||||
l4g.Debug("/api/v3/users/websocket:%s", r.Action)
|
||||
|
||||
r.Session = *GetSession(conn.SessionToken)
|
||||
r.T = conn.T
|
||||
r.Locale = conn.Locale
|
||||
|
||||
data := make(map[string]interface{})
|
||||
|
||||
if err := wh.handlerFunc(r, data); err != nil {
|
||||
l4g.Error(utils.T("api.web_socket_handler.log.error"), "/api/v3/users/websocket", r.Action, r.Seq, r.Session.UserId, err.SystemMessage(utils.T), err.DetailedError)
|
||||
err.DetailedError = ""
|
||||
conn.Send <- model.NewWebSocketError(r.Seq, err)
|
||||
return
|
||||
}
|
||||
|
||||
conn.Send <- model.NewWebSocketResponse(model.STATUS_OK, r.Seq, data)
|
||||
}
|
||||
|
||||
func NewInvalidWebSocketParamError(action string, name string) *model.AppError {
|
||||
return model.NewLocAppError("/api/v3/users/websocket:"+action, "api.websocket_handler.invalid_param.app_error", map[string]interface{}{"Name": name}, "")
|
||||
}
|
||||
59
api/websocket_router.go
Обычный файл
59
api/websocket_router.go
Обычный файл
@@ -0,0 +1,59 @@
|
||||
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
|
||||
// See License.txt for license information.
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
l4g "github.com/alecthomas/log4go"
|
||||
|
||||
"github.com/mattermost/platform/model"
|
||||
"github.com/mattermost/platform/utils"
|
||||
)
|
||||
|
||||
type WebSocketRouter struct {
|
||||
handlers map[string]*webSocketHandler
|
||||
}
|
||||
|
||||
func NewWebSocketRouter() *WebSocketRouter {
|
||||
router := &WebSocketRouter{}
|
||||
router.handlers = make(map[string]*webSocketHandler)
|
||||
return router
|
||||
}
|
||||
|
||||
func (wr *WebSocketRouter) Handle(action string, handler *webSocketHandler) {
|
||||
wr.handlers[action] = handler
|
||||
}
|
||||
|
||||
func (wr *WebSocketRouter) ServeWebSocket(conn *WebConn, r *model.WebSocketRequest) {
|
||||
if r.Action == "" {
|
||||
err := model.NewLocAppError("ServeWebSocket", "api.web_socket_router.no_action.app_error", nil, "")
|
||||
wr.ReturnWebSocketError(conn, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if r.Seq <= 0 {
|
||||
err := model.NewLocAppError("ServeWebSocket", "api.web_socket_router.bad_seq.app_error", nil, "")
|
||||
wr.ReturnWebSocketError(conn, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
var handler *webSocketHandler
|
||||
if h, ok := wr.handlers[r.Action]; !ok {
|
||||
err := model.NewLocAppError("ServeWebSocket", "api.web_socket_router.bad_action.app_error", nil, "")
|
||||
wr.ReturnWebSocketError(conn, r, err)
|
||||
return
|
||||
} else {
|
||||
handler = h
|
||||
}
|
||||
|
||||
handler.ServeWebSocket(conn, r)
|
||||
}
|
||||
|
||||
func (wr *WebSocketRouter) ReturnWebSocketError(conn *WebConn, r *model.WebSocketRequest, err *model.AppError) {
|
||||
l4g.Error(utils.T("api.web_socket_router.log.error"), r.Seq, conn.UserId, err.SystemMessage(utils.T), err.DetailedError)
|
||||
|
||||
err.DetailedError = ""
|
||||
errorResp := model.NewWebSocketError(r.Seq, err)
|
||||
|
||||
conn.Send <- errorResp
|
||||
}
|
||||
144
api/websocket_test.go
Обычный файл
144
api/websocket_test.go
Обычный файл
@@ -0,0 +1,144 @@
|
||||
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
|
||||
// See License.txt for license information.
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mattermost/platform/model"
|
||||
)
|
||||
|
||||
func TestWebSocket(t *testing.T) {
|
||||
th := Setup().InitBasic()
|
||||
WebSocketClient, err := th.CreateWebSocketClient()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer WebSocketClient.Close()
|
||||
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
|
||||
// Test closing and reconnecting
|
||||
WebSocketClient.Close()
|
||||
if err := WebSocketClient.Connect(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
WebSocketClient.Listen()
|
||||
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
|
||||
WebSocketClient.SendMessage("ping", nil)
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
if resp := <-WebSocketClient.ResponseChannel; resp.Data["text"].(string) != "pong" {
|
||||
t.Fatal("wrong response")
|
||||
}
|
||||
|
||||
WebSocketClient.SendMessage("", nil)
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
if resp := <-WebSocketClient.ResponseChannel; resp.Error.Id != "api.web_socket_router.no_action.app_error" {
|
||||
t.Fatal("should have been no action response")
|
||||
}
|
||||
|
||||
WebSocketClient.SendMessage("junk", nil)
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
if resp := <-WebSocketClient.ResponseChannel; resp.Error.Id != "api.web_socket_router.bad_action.app_error" {
|
||||
t.Fatal("should have been bad action response")
|
||||
}
|
||||
|
||||
req := &model.WebSocketRequest{}
|
||||
req.Seq = 0
|
||||
req.Action = "ping"
|
||||
WebSocketClient.Conn.WriteJSON(req)
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
if resp := <-WebSocketClient.ResponseChannel; resp.Error.Id != "api.web_socket_router.bad_seq.app_error" {
|
||||
t.Fatal("should have been bad action response")
|
||||
}
|
||||
|
||||
WebSocketClient.UserTyping("", "")
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
if resp := <-WebSocketClient.ResponseChannel; resp.Error.Id != "api.websocket_handler.invalid_param.app_error" {
|
||||
t.Fatal("should have been invalid param response")
|
||||
} else {
|
||||
if resp.Error.DetailedError != "" {
|
||||
t.Fatal("detailed error not cleared")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebSocketEvent(t *testing.T) {
|
||||
th := Setup().InitBasic()
|
||||
WebSocketClient, err := th.CreateWebSocketClient()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer WebSocketClient.Close()
|
||||
|
||||
WebSocketClient.Listen()
|
||||
|
||||
evt1 := model.NewWebSocketEvent(th.BasicTeam.Id, th.BasicChannel.Id, "somerandomid", model.WEBSOCKET_EVENT_TYPING)
|
||||
go Publish(evt1)
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
|
||||
stop := make(chan bool)
|
||||
eventHit := false
|
||||
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case resp := <-WebSocketClient.EventChannel:
|
||||
if resp.Event == model.WEBSOCKET_EVENT_TYPING && resp.UserId == "somerandomid" {
|
||||
eventHit = true
|
||||
}
|
||||
case <-stop:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
|
||||
stop <- true
|
||||
|
||||
if !eventHit {
|
||||
t.Fatal("did not receive typing event")
|
||||
}
|
||||
|
||||
evt2 := model.NewWebSocketEvent(th.BasicTeam.Id, "somerandomid", "somerandomid", model.WEBSOCKET_EVENT_TYPING)
|
||||
go Publish(evt2)
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
|
||||
eventHit = false
|
||||
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case resp := <-WebSocketClient.EventChannel:
|
||||
if resp.Event == model.WEBSOCKET_EVENT_TYPING {
|
||||
eventHit = true
|
||||
}
|
||||
case <-stop:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
|
||||
stop <- true
|
||||
|
||||
if eventHit {
|
||||
t.Fatal("got typing event for bad channel id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestZZWebSocketTearDown(t *testing.T) {
|
||||
// *IMPORTANT* - Kind of hacky
|
||||
// This should be the last function in any test file
|
||||
// that calls Setup()
|
||||
// Should be in the last file too sorted by name
|
||||
time.Sleep(2 * time.Second)
|
||||
TearDown()
|
||||
}
|
||||
12
i18n/en.json
12
i18n/en.json
@@ -599,6 +599,18 @@
|
||||
"id": "api.context.log.error",
|
||||
"translation": "%v:%v code=%v rid=%v uid=%v ip=%v %v [details: %v]"
|
||||
},
|
||||
{
|
||||
"id": "api.web_socket_router.log.error",
|
||||
"translation": "websocket routing error: seq=%v uid=%v %v [details: %v]"
|
||||
},
|
||||
{
|
||||
"id": "api.web_socket_handler.log.error",
|
||||
"translation": "%v:%v seq=%v uid=%v %v [details: %v]"
|
||||
},
|
||||
{
|
||||
"id": "api.websocket_handler.invalid_param.app_error",
|
||||
"translation": "Invalid {{.Name}} parameter"
|
||||
},
|
||||
{
|
||||
"id": "api.context.permissions.app_error",
|
||||
"translation": "You do not have the appropriate permissions"
|
||||
|
||||
@@ -32,6 +32,7 @@ const (
|
||||
HEADER_REQUESTED_WITH_XML = "XMLHttpRequest"
|
||||
STATUS = "status"
|
||||
STATUS_OK = "OK"
|
||||
STATUS_FAIL = "FAIL"
|
||||
|
||||
API_URL_SUFFIX_V1 = "/api/v1"
|
||||
API_URL_SUFFIX_V3 = "/api/v3"
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
|
||||
// See License.txt for license information.
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
)
|
||||
|
||||
const (
|
||||
ACTION_TYPING = "typing"
|
||||
ACTION_POSTED = "posted"
|
||||
ACTION_POST_EDITED = "post_edited"
|
||||
ACTION_POST_DELETED = "post_deleted"
|
||||
ACTION_CHANNEL_DELETED = "channel_deleted"
|
||||
ACTION_CHANNEL_VIEWED = "channel_viewed"
|
||||
ACTION_DIRECT_ADDED = "direct_added"
|
||||
ACTION_NEW_USER = "new_user"
|
||||
ACTION_LEAVE_TEAM = "leave_team"
|
||||
ACTION_USER_ADDED = "user_added"
|
||||
ACTION_USER_REMOVED = "user_removed"
|
||||
ACTION_PREFERENCE_CHANGED = "preference_changed"
|
||||
ACTION_EPHEMERAL_MESSAGE = "ephemeral_message"
|
||||
)
|
||||
|
||||
type Message struct {
|
||||
TeamId string `json:"team_id"`
|
||||
ChannelId string `json:"channel_id"`
|
||||
UserId string `json:"user_id"`
|
||||
Action string `json:"action"`
|
||||
Props map[string]string `json:"props"`
|
||||
}
|
||||
|
||||
func (m *Message) Add(key string, value string) {
|
||||
m.Props[key] = value
|
||||
}
|
||||
|
||||
func NewMessage(teamId string, channelId string, userId string, action string) *Message {
|
||||
return &Message{TeamId: teamId, ChannelId: channelId, UserId: userId, Action: action, Props: make(map[string]string)}
|
||||
}
|
||||
|
||||
func (o *Message) ToJson() string {
|
||||
b, err := json.Marshal(o)
|
||||
if err != nil {
|
||||
return ""
|
||||
} else {
|
||||
return string(b)
|
||||
}
|
||||
}
|
||||
|
||||
func MessageFromJson(data io.Reader) *Message {
|
||||
decoder := json.NewDecoder(data)
|
||||
var o Message
|
||||
err := decoder.Decode(&o)
|
||||
if err == nil {
|
||||
return &o
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
|
||||
// See License.txt for license information.
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMessgaeJson(t *testing.T) {
|
||||
m := NewMessage(NewId(), NewId(), NewId(), ACTION_TYPING)
|
||||
m.Add("RootId", NewId())
|
||||
json := m.ToJson()
|
||||
result := MessageFromJson(strings.NewReader(json))
|
||||
|
||||
if m.TeamId != result.TeamId {
|
||||
t.Fatal("Ids do not match")
|
||||
}
|
||||
|
||||
if m.Props["RootId"] != result.Props["RootId"] {
|
||||
t.Fatal("Ids do not match")
|
||||
}
|
||||
}
|
||||
@@ -34,12 +34,12 @@ type EncryptStringMap map[string]string
|
||||
|
||||
type AppError struct {
|
||||
Id string `json:"id"`
|
||||
Message string `json:"message"` // Message to be display to the end user without debugging information
|
||||
DetailedError string `json:"detailed_error"` // Internal error string to help the developer
|
||||
RequestId string `json:"request_id"` // The RequestId that's also set in the header
|
||||
StatusCode int `json:"status_code"` // The http status code
|
||||
Where string `json:"-"` // The function where it happened in the form of Struct.Func
|
||||
IsOAuth bool `json:"is_oauth"` // Whether the error is OAuth specific
|
||||
Message string `json:"message"` // Message to be display to the end user without debugging information
|
||||
DetailedError string `json:"detailed_error"` // Internal error string to help the developer
|
||||
RequestId string `json:"request_id,omitempty"` // The RequestId that's also set in the header
|
||||
StatusCode int `json:"status_code,omitempty"` // The http status code
|
||||
Where string `json:"-"` // The function where it happened in the form of Struct.Func
|
||||
IsOAuth bool `json:"is_oauth,omitempty"` // Whether the error is OAuth specific
|
||||
params map[string]interface{} `json:"-"`
|
||||
}
|
||||
|
||||
|
||||
102
model/websocket_client.go
Обычный файл
102
model/websocket_client.go
Обычный файл
@@ -0,0 +1,102 @@
|
||||
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
|
||||
// See License.txt for license information.
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"github.com/gorilla/websocket"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type WebSocketClient struct {
|
||||
Url string // The location of the server like "ws://localhost:8065"
|
||||
ApiUrl string // The api location of the server like "ws://localhost:8065/api/v3"
|
||||
Conn *websocket.Conn // The WebSocket connection
|
||||
AuthToken string // The token used to open the WebSocket
|
||||
Sequence int64 // The ever-incrementing sequence attached to each WebSocket action
|
||||
EventChannel chan *WebSocketEvent
|
||||
ResponseChannel chan *WebSocketResponse
|
||||
}
|
||||
|
||||
// NewWebSocketClient constructs a new WebSocket client with convienence
|
||||
// methods for talking to the server.
|
||||
func NewWebSocketClient(url, authToken string) (*WebSocketClient, *AppError) {
|
||||
header := http.Header{}
|
||||
header.Set(HEADER_AUTH, "BEARER "+authToken)
|
||||
conn, _, err := websocket.DefaultDialer.Dial(url+API_URL_SUFFIX+"/users/websocket", header)
|
||||
if err != nil {
|
||||
return nil, NewLocAppError("NewWebSocketClient", "model.websocket_client.connect_fail.app_error", nil, err.Error())
|
||||
}
|
||||
|
||||
return &WebSocketClient{
|
||||
url,
|
||||
url + API_URL_SUFFIX,
|
||||
conn,
|
||||
authToken,
|
||||
1,
|
||||
make(chan *WebSocketEvent, 100),
|
||||
make(chan *WebSocketResponse, 100),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (wsc *WebSocketClient) Connect() *AppError {
|
||||
header := http.Header{}
|
||||
header.Set(HEADER_AUTH, "BEARER "+wsc.AuthToken)
|
||||
|
||||
var err error
|
||||
wsc.Conn, _, err = websocket.DefaultDialer.Dial(wsc.ApiUrl+"/users/websocket", header)
|
||||
if err != nil {
|
||||
return NewLocAppError("NewWebSocketClient", "model.websocket_client.connect_fail.app_error", nil, err.Error())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (wsc *WebSocketClient) Close() {
|
||||
wsc.Conn.Close()
|
||||
}
|
||||
|
||||
func (wsc *WebSocketClient) Listen() {
|
||||
go func() {
|
||||
for {
|
||||
var rawMsg json.RawMessage
|
||||
var err error
|
||||
if _, rawMsg, err = wsc.Conn.ReadMessage(); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var event WebSocketEvent
|
||||
if err := json.Unmarshal(rawMsg, &event); err == nil && event.IsValid() {
|
||||
wsc.EventChannel <- &event
|
||||
continue
|
||||
}
|
||||
|
||||
var response WebSocketResponse
|
||||
if err := json.Unmarshal(rawMsg, &response); err == nil && response.IsValid() {
|
||||
wsc.ResponseChannel <- &response
|
||||
continue
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (wsc *WebSocketClient) SendMessage(action string, data map[string]interface{}) {
|
||||
req := &WebSocketRequest{}
|
||||
req.Seq = wsc.Sequence
|
||||
req.Action = action
|
||||
req.Data = data
|
||||
|
||||
wsc.Sequence++
|
||||
|
||||
wsc.Conn.WriteJSON(req)
|
||||
}
|
||||
|
||||
func (wsc *WebSocketClient) UserTyping(channelId, parentId string) {
|
||||
data := map[string]interface{}{
|
||||
"channel_id": channelId,
|
||||
"parent_id": parentId,
|
||||
}
|
||||
|
||||
wsc.SendMessage("user_typing", data)
|
||||
}
|
||||
114
model/websocket_message.go
Обычный файл
114
model/websocket_message.go
Обычный файл
@@ -0,0 +1,114 @@
|
||||
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
|
||||
// See License.txt for license information.
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
)
|
||||
|
||||
const (
|
||||
WEBSOCKET_EVENT_TYPING = "typing"
|
||||
WEBSOCKET_EVENT_POSTED = "posted"
|
||||
WEBSOCKET_EVENT_POST_EDITED = "post_edited"
|
||||
WEBSOCKET_EVENT_POST_DELETED = "post_deleted"
|
||||
WEBSOCKET_EVENT_CHANNEL_DELETED = "channel_deleted"
|
||||
WEBSOCKET_EVENT_CHANNEL_VIEWED = "channel_viewed"
|
||||
WEBSOCKET_EVENT_DIRECT_ADDED = "direct_added"
|
||||
WEBSOCKET_EVENT_NEW_USER = "new_user"
|
||||
WEBSOCKET_EVENT_LEAVE_TEAM = "leave_team"
|
||||
WEBSOCKET_EVENT_USER_ADDED = "user_added"
|
||||
WEBSOCKET_EVENT_USER_REMOVED = "user_removed"
|
||||
WEBSOCKET_EVENT_PREFERENCE_CHANGED = "preference_changed"
|
||||
WEBSOCKET_EVENT_EPHEMERAL_MESSAGE = "ephemeral_message"
|
||||
WEBSOCKET_EVENT_STATUS_CHANGE = "status_change"
|
||||
)
|
||||
|
||||
type WebSocketMessage interface {
|
||||
ToJson() string
|
||||
IsValid() bool
|
||||
}
|
||||
|
||||
type WebSocketEvent struct {
|
||||
TeamId string `json:"team_id"`
|
||||
ChannelId string `json:"channel_id"`
|
||||
UserId string `json:"user_id"`
|
||||
Event string `json:"event"`
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
|
||||
func (m *WebSocketEvent) Add(key string, value interface{}) {
|
||||
m.Data[key] = value
|
||||
}
|
||||
|
||||
func NewWebSocketEvent(teamId string, channelId string, userId string, event string) *WebSocketEvent {
|
||||
return &WebSocketEvent{TeamId: teamId, ChannelId: channelId, UserId: userId, Event: event, Data: make(map[string]interface{})}
|
||||
}
|
||||
|
||||
func (o *WebSocketEvent) IsValid() bool {
|
||||
return o.Event != ""
|
||||
}
|
||||
|
||||
func (o *WebSocketEvent) ToJson() string {
|
||||
b, err := json.Marshal(o)
|
||||
if err != nil {
|
||||
return ""
|
||||
} else {
|
||||
return string(b)
|
||||
}
|
||||
}
|
||||
|
||||
func WebSocketEventFromJson(data io.Reader) *WebSocketEvent {
|
||||
decoder := json.NewDecoder(data)
|
||||
var o WebSocketEvent
|
||||
err := decoder.Decode(&o)
|
||||
if err == nil {
|
||||
return &o
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
type WebSocketResponse struct {
|
||||
Status string `json:"status"`
|
||||
SeqReply int64 `json:"seq_reply,omitempty"`
|
||||
Data map[string]interface{} `json:"data,omitempty"`
|
||||
Error *AppError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (m *WebSocketResponse) Add(key string, value interface{}) {
|
||||
m.Data[key] = value
|
||||
}
|
||||
|
||||
func NewWebSocketResponse(status string, seqReply int64, data map[string]interface{}) *WebSocketResponse {
|
||||
return &WebSocketResponse{Status: status, SeqReply: seqReply, Data: data}
|
||||
}
|
||||
|
||||
func NewWebSocketError(seqReply int64, err *AppError) *WebSocketResponse {
|
||||
return &WebSocketResponse{Status: STATUS_FAIL, SeqReply: seqReply, Error: err}
|
||||
}
|
||||
|
||||
func (o *WebSocketResponse) IsValid() bool {
|
||||
return o.Status != ""
|
||||
}
|
||||
|
||||
func (o *WebSocketResponse) ToJson() string {
|
||||
b, err := json.Marshal(o)
|
||||
if err != nil {
|
||||
return ""
|
||||
} else {
|
||||
return string(b)
|
||||
}
|
||||
}
|
||||
|
||||
func WebSocketResponseFromJson(data io.Reader) *WebSocketResponse {
|
||||
decoder := json.NewDecoder(data)
|
||||
var o WebSocketResponse
|
||||
err := decoder.Decode(&o)
|
||||
if err == nil {
|
||||
return &o
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
56
model/websocket_message_test.go
Обычный файл
56
model/websocket_message_test.go
Обычный файл
@@ -0,0 +1,56 @@
|
||||
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
|
||||
// See License.txt for license information.
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWebSocketEvent(t *testing.T) {
|
||||
m := NewWebSocketEvent(NewId(), NewId(), NewId(), "some_event")
|
||||
m.Add("RootId", NewId())
|
||||
json := m.ToJson()
|
||||
result := WebSocketEventFromJson(strings.NewReader(json))
|
||||
|
||||
badresult := WebSocketEventFromJson(strings.NewReader("junk"))
|
||||
if badresult != nil {
|
||||
t.Fatal("should not have parsed")
|
||||
}
|
||||
|
||||
if !m.IsValid() {
|
||||
t.Fatal("should be valid")
|
||||
}
|
||||
|
||||
if m.TeamId != result.TeamId {
|
||||
t.Fatal("Ids do not match")
|
||||
}
|
||||
|
||||
if m.Data["RootId"] != result.Data["RootId"] {
|
||||
t.Fatal("Ids do not match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebSocketResponse(t *testing.T) {
|
||||
m := NewWebSocketResponse("OK", 1, map[string]interface{}{})
|
||||
e := NewWebSocketError(1, &AppError{})
|
||||
m.Add("RootId", NewId())
|
||||
json := m.ToJson()
|
||||
result := WebSocketResponseFromJson(strings.NewReader(json))
|
||||
json2 := e.ToJson()
|
||||
WebSocketResponseFromJson(strings.NewReader(json2))
|
||||
|
||||
badresult := WebSocketResponseFromJson(strings.NewReader("junk"))
|
||||
if badresult != nil {
|
||||
t.Fatal("should not have parsed")
|
||||
}
|
||||
|
||||
if !m.IsValid() {
|
||||
t.Fatal("should be valid")
|
||||
}
|
||||
|
||||
if m.Data["RootId"] != result.Data["RootId"] {
|
||||
t.Fatal("Ids do not match")
|
||||
}
|
||||
}
|
||||
43
model/websocket_request.go
Обычный файл
43
model/websocket_request.go
Обычный файл
@@ -0,0 +1,43 @@
|
||||
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
|
||||
// See License.txt for license information.
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
|
||||
goi18n "github.com/nicksnyder/go-i18n/i18n"
|
||||
)
|
||||
|
||||
type WebSocketRequest struct {
|
||||
// Client-provided fields
|
||||
Seq int64 `json:"seq"`
|
||||
Action string `json:"action"`
|
||||
Data map[string]interface{} `json:"data"`
|
||||
|
||||
// Server-provided fields
|
||||
Session Session `json:"-"`
|
||||
T goi18n.TranslateFunc `json:"-"`
|
||||
Locale string `json:"-"`
|
||||
}
|
||||
|
||||
func (o *WebSocketRequest) ToJson() string {
|
||||
b, err := json.Marshal(o)
|
||||
if err != nil {
|
||||
return ""
|
||||
} else {
|
||||
return string(b)
|
||||
}
|
||||
}
|
||||
|
||||
func WebSocketRequestFromJson(data io.Reader) *WebSocketRequest {
|
||||
decoder := json.NewDecoder(data)
|
||||
var o WebSocketRequest
|
||||
err := decoder.Decode(&o)
|
||||
if err == nil {
|
||||
return &o
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
25
model/websocket_request_test.go
Обычный файл
25
model/websocket_request_test.go
Обычный файл
@@ -0,0 +1,25 @@
|
||||
// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved.
|
||||
// See License.txt for license information.
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWebSocketRequest(t *testing.T) {
|
||||
m := WebSocketRequest{Seq: 1, Action: "test"}
|
||||
json := m.ToJson()
|
||||
result := WebSocketRequestFromJson(strings.NewReader(json))
|
||||
|
||||
if result == nil {
|
||||
t.Fatal("should not be nil")
|
||||
}
|
||||
|
||||
badresult := WebSocketRequestFromJson(strings.NewReader("junk"))
|
||||
|
||||
if badresult != nil {
|
||||
t.Fatal("should have been nil")
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import TeamStore from 'stores/team_store.jsx';
|
||||
import PreferenceStore from 'stores/preference_store.jsx';
|
||||
import SearchStore from 'stores/search_store.jsx';
|
||||
|
||||
import * as Websockets from 'actions/websocket_actions.jsx';
|
||||
import {handleNewPost} from 'actions/post_actions.jsx';
|
||||
|
||||
import Constants from 'utils/constants.jsx';
|
||||
@@ -20,6 +19,7 @@ const ActionTypes = Constants.ActionTypes;
|
||||
|
||||
import Client from 'utils/web_client.jsx';
|
||||
import * as AsyncClient from 'utils/async_client.jsx';
|
||||
import WebSocketClient from 'utils/websocket_client.jsx';
|
||||
import * as Utils from 'utils/utils.jsx';
|
||||
|
||||
import en from 'i18n/en.json';
|
||||
@@ -439,7 +439,7 @@ var lastTimeTypingSent = 0;
|
||||
export function emitLocalUserTypingEvent(channelId, parentId) {
|
||||
const t = Date.now();
|
||||
if ((t - lastTimeTypingSent) > Constants.UPDATE_TYPING_MS) {
|
||||
Websockets.sendMessage({channel_id: channelId, action: 'typing', props: {parent_id: parentId}, state: {}});
|
||||
WebSocketClient.userTyping(channelId, parentId);
|
||||
lastTimeTypingSent = t;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import ErrorStore from 'stores/error_store.jsx';
|
||||
import NotificationStore from 'stores/notification_store.jsx'; //eslint-disable-line no-unused-vars
|
||||
|
||||
import Client from 'utils/web_client.jsx';
|
||||
import WebSocketClient from 'utils/websocket_client.jsx';
|
||||
import * as Utils from 'utils/utils.jsx';
|
||||
import * as AsyncClient from 'utils/async_client.jsx';
|
||||
import * as GlobalActions from 'actions/global_actions.jsx';
|
||||
@@ -23,16 +24,9 @@ const SocketEvents = Constants.SocketEvents;
|
||||
import {browserHistory} from 'react-router/es6';
|
||||
|
||||
const MAX_WEBSOCKET_FAILS = 7;
|
||||
const MIN_WEBSOCKET_RETRY_TIME = 3000; // 3 sec
|
||||
const MAX_WEBSOCKET_RETRY_TIME = 300000; // 5 mins
|
||||
|
||||
var conn = null;
|
||||
var connectFailCount = 0;
|
||||
var pastFirstInit = false;
|
||||
var manuallyClosed = false;
|
||||
|
||||
export function initialize() {
|
||||
if (window.WebSocket && !conn) {
|
||||
if (window.WebSocket) {
|
||||
let protocol = 'ws://';
|
||||
if (window.location.protocol === 'https:') {
|
||||
protocol = 'wss://';
|
||||
@@ -40,85 +34,35 @@ export function initialize() {
|
||||
|
||||
const connUrl = protocol + location.host + ((/:\d+/).test(location.host) ? '' : Utils.getWebsocketPort(protocol)) + Client.getUsersRoute() + '/websocket';
|
||||
|
||||
if (connectFailCount === 0) {
|
||||
console.log('websocket connecting to ' + connUrl); //eslint-disable-line no-console
|
||||
}
|
||||
|
||||
manuallyClosed = false;
|
||||
|
||||
conn = new WebSocket(connUrl);
|
||||
|
||||
conn.onopen = () => {
|
||||
if (connectFailCount > 0) {
|
||||
console.log('websocket re-established connection'); //eslint-disable-line no-console
|
||||
AsyncClient.getChannels();
|
||||
AsyncClient.getPosts(ChannelStore.getCurrentId());
|
||||
}
|
||||
|
||||
if (pastFirstInit) {
|
||||
ErrorStore.clearLastError();
|
||||
ErrorStore.emitChange();
|
||||
}
|
||||
|
||||
pastFirstInit = true;
|
||||
connectFailCount = 0;
|
||||
};
|
||||
|
||||
conn.onclose = () => {
|
||||
conn = null;
|
||||
|
||||
if (connectFailCount === 0) {
|
||||
console.log('websocket closed'); //eslint-disable-line no-console
|
||||
}
|
||||
|
||||
if (manuallyClosed) {
|
||||
return;
|
||||
}
|
||||
|
||||
connectFailCount = connectFailCount + 1;
|
||||
|
||||
var retryTime = MIN_WEBSOCKET_RETRY_TIME;
|
||||
|
||||
if (connectFailCount > MAX_WEBSOCKET_FAILS) {
|
||||
ErrorStore.storeLastError({message: Utils.localizeMessage('channel_loader.socketError', 'Please check connection, Mattermost unreachable. If issue persists, ask administrator to check WebSocket port.')});
|
||||
|
||||
// If we've failed a bunch of connections then start backing off
|
||||
retryTime = MIN_WEBSOCKET_RETRY_TIME * connectFailCount * connectFailCount;
|
||||
if (retryTime > MAX_WEBSOCKET_RETRY_TIME) {
|
||||
retryTime = MAX_WEBSOCKET_RETRY_TIME;
|
||||
}
|
||||
}
|
||||
|
||||
ErrorStore.setConnectionErrorCount(connectFailCount);
|
||||
ErrorStore.emitChange();
|
||||
|
||||
setTimeout(
|
||||
() => {
|
||||
initialize();
|
||||
},
|
||||
retryTime
|
||||
);
|
||||
};
|
||||
|
||||
conn.onerror = (evt) => {
|
||||
if (connectFailCount <= 1) {
|
||||
console.log('websocket error'); //eslint-disable-line no-console
|
||||
console.log(evt); //eslint-disable-line no-console
|
||||
}
|
||||
};
|
||||
|
||||
conn.onmessage = (evt) => {
|
||||
const msg = JSON.parse(evt.data);
|
||||
handleMessage(msg);
|
||||
};
|
||||
WebSocketClient.initialize(connUrl);
|
||||
WebSocketClient.setEventCallback(handleEvent);
|
||||
WebSocketClient.setReconnectCallback(handleReconnect);
|
||||
WebSocketClient.setCloseCallback(handleClose);
|
||||
}
|
||||
}
|
||||
|
||||
function handleMessage(msg) {
|
||||
// Let the store know we are online. This probably shouldn't be here.
|
||||
UserStore.setStatus(msg.user_id, 'online');
|
||||
export function close() {
|
||||
WebSocketClient.close();
|
||||
}
|
||||
|
||||
switch (msg.action) {
|
||||
function handleReconnect() {
|
||||
AsyncClient.getChannels();
|
||||
AsyncClient.getPosts(ChannelStore.getCurrentId());
|
||||
ErrorStore.clearLastError();
|
||||
ErrorStore.emitChange();
|
||||
}
|
||||
|
||||
function handleClose(failCount) {
|
||||
if (failCount > MAX_WEBSOCKET_FAILS) {
|
||||
ErrorStore.storeLastError({message: Utils.localizeMessage('channel_loader.socketError', 'Please check connection, Mattermost unreachable. If issue persists, ask administrator to check WebSocket port.')});
|
||||
}
|
||||
|
||||
ErrorStore.setConnectionErrorCount(failCount);
|
||||
ErrorStore.emitChange();
|
||||
}
|
||||
|
||||
function handleEvent(msg) {
|
||||
switch (msg.event) {
|
||||
case SocketEvents.POSTED:
|
||||
case SocketEvents.EPHEMERAL_MESSAGE:
|
||||
handleNewPostEvent(msg);
|
||||
@@ -172,36 +116,14 @@ function handleMessage(msg) {
|
||||
}
|
||||
}
|
||||
|
||||
export function sendMessage(msg) {
|
||||
if (conn && conn.readyState === WebSocket.OPEN) {
|
||||
var teamId = TeamStore.getCurrentId();
|
||||
if (teamId && teamId.length > 0) {
|
||||
msg.team_id = teamId;
|
||||
}
|
||||
|
||||
conn.send(JSON.stringify(msg));
|
||||
} else if (!conn || conn.readyState === WebSocket.Closed) {
|
||||
conn = null;
|
||||
initialize();
|
||||
}
|
||||
}
|
||||
|
||||
export function close() {
|
||||
manuallyClosed = true;
|
||||
connectFailCount = 0;
|
||||
if (conn && conn.readyState === WebSocket.OPEN) {
|
||||
conn.close();
|
||||
}
|
||||
}
|
||||
|
||||
function handleNewPostEvent(msg) {
|
||||
const post = JSON.parse(msg.props.post);
|
||||
const post = JSON.parse(msg.data.post);
|
||||
handleNewPost(post, msg);
|
||||
}
|
||||
|
||||
function handlePostEditEvent(msg) {
|
||||
// Store post
|
||||
const post = JSON.parse(msg.props.post);
|
||||
const post = JSON.parse(msg.data.post);
|
||||
PostStore.storePost(post);
|
||||
PostStore.emitChange();
|
||||
|
||||
@@ -214,7 +136,7 @@ function handlePostEditEvent(msg) {
|
||||
}
|
||||
|
||||
function handlePostDeleteEvent(msg) {
|
||||
const post = JSON.parse(msg.props.post);
|
||||
const post = JSON.parse(msg.data.post);
|
||||
GlobalActions.emitPostDeletedEvent(post);
|
||||
}
|
||||
|
||||
@@ -257,12 +179,12 @@ function handleUserRemovedEvent(msg) {
|
||||
if (UserStore.getCurrentId() === msg.user_id) {
|
||||
AsyncClient.getChannels();
|
||||
|
||||
if (msg.props.remover_id !== msg.user_id &&
|
||||
if (msg.data.remover_id !== msg.user_id &&
|
||||
msg.channel_id === ChannelStore.getCurrentId() &&
|
||||
$('#removed_from_channel').length > 0) {
|
||||
var sentState = {};
|
||||
sentState.channelName = ChannelStore.getCurrent().display_name;
|
||||
sentState.remover = UserStore.getProfile(msg.props.remover_id).username;
|
||||
sentState.remover = UserStore.getProfile(msg.data.remover_id).username;
|
||||
|
||||
BrowserStore.setItem('channel-removed-state', sentState);
|
||||
$('#removed_from_channel').modal('show');
|
||||
@@ -290,12 +212,10 @@ function handleChannelDeletedEvent(msg) {
|
||||
}
|
||||
|
||||
function handlePreferenceChangedEvent(msg) {
|
||||
const preference = JSON.parse(msg.props.preference);
|
||||
const preference = JSON.parse(msg.data.preference);
|
||||
GlobalActions.emitPreferenceChangedEvent(preference);
|
||||
}
|
||||
|
||||
function handleUserTypingEvent(msg) {
|
||||
if (TeamStore.getCurrentId() === msg.team_id) {
|
||||
GlobalActions.emitRemoteUserTypingEvent(msg.channel_id, msg.user_id, msg.props.parent_id);
|
||||
}
|
||||
GlobalActions.emitRemoteUserTypingEvent(msg.channel_id, msg.user_id, msg.data.parent_id);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"keymirror": "0.1.1",
|
||||
"marked": "mattermost/marked#12d2be4cdf54d4ec95fead934e18840b6a2c1a7b",
|
||||
"match-at": "0.1.0",
|
||||
"mattermost": "mattermost/mattermost-javascript#5815f14f0d1960aa4c99797b09d949d2959eb24f",
|
||||
"mattermost": "mattermost/mattermost-javascript#4cdaeba22ff82bf93dc417af1ab4e89e3248d624",
|
||||
"object-assign": "4.1.0",
|
||||
"perfect-scrollbar": "0.6.11",
|
||||
"react": "15.0.2",
|
||||
|
||||
7
webapp/utils/websocket_client.jsx
Обычный файл
7
webapp/utils/websocket_client.jsx
Обычный файл
@@ -0,0 +1,7 @@
|
||||
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
|
||||
// See License.txt for license information.
|
||||
|
||||
import WebSocketClient from 'mattermost/websocket_client.jsx';
|
||||
|
||||
var WebClient = new WebSocketClient();
|
||||
export default WebClient;
|
||||
@@ -52,6 +52,15 @@ var config = {
|
||||
cacheDirectory: DEV
|
||||
}
|
||||
},
|
||||
{
|
||||
test: /node_modules\/mattermost\/websocket_client\.jsx?$/,
|
||||
loader: 'babel',
|
||||
query: {
|
||||
presets: ['react', 'es2015-webpack', 'stage-0'],
|
||||
plugins: ['transform-runtime'],
|
||||
cacheDirectory: DEV
|
||||
}
|
||||
},
|
||||
{
|
||||
test: /\.json$/,
|
||||
loader: 'json'
|
||||
|
||||
Ссылка в новой задаче
Block a user