2021-09-29 18:19:34 +02:00
|
|
|
//go:generate mockgen --build_flags=--mod=mod -destination=mocks/mockpluginapi.go -package mocks github.com/mattermost/mattermost-server/v6/plugin API
|
2021-08-27 10:59:14 +02:00
|
|
|
package ws
|
|
|
|
|
|
|
|
import (
|
|
|
|
"fmt"
|
|
|
|
"strings"
|
|
|
|
"sync"
|
2021-09-29 18:19:34 +02:00
|
|
|
"sync/atomic"
|
2021-08-27 10:59:14 +02:00
|
|
|
"time"
|
|
|
|
|
|
|
|
"github.com/mattermost/focalboard/server/auth"
|
|
|
|
"github.com/mattermost/focalboard/server/model"
|
2021-09-16 21:31:02 +02:00
|
|
|
"github.com/mattermost/focalboard/server/utils"
|
2021-10-07 13:51:01 +02:00
|
|
|
|
2021-08-27 10:59:14 +02:00
|
|
|
mmModel "github.com/mattermost/mattermost-server/v6/model"
|
|
|
|
"github.com/mattermost/mattermost-server/v6/plugin"
|
|
|
|
)
|
|
|
|
|
|
|
|
const websocketMessagePrefix = "custom_focalboard_"
|
|
|
|
|
|
|
|
var errMissingWorkspaceInCommand = fmt.Errorf("command doesn't contain workspaceId")
|
|
|
|
|
2021-09-16 21:31:02 +02:00
|
|
|
type PluginAdapterInterface interface {
|
|
|
|
OnWebSocketConnect(webConnID, userID string)
|
|
|
|
OnWebSocketDisconnect(webConnID, userID string)
|
|
|
|
WebSocketMessageHasBeenPosted(webConnID, userID string, req *mmModel.WebSocketRequest)
|
|
|
|
BroadcastConfigChange(clientConfig model.ClientConfig)
|
|
|
|
BroadcastBlockChange(workspaceID string, block model.Block)
|
|
|
|
BroadcastBlockDelete(workspaceID, blockID, parentID string)
|
2021-12-10 16:46:37 +01:00
|
|
|
BroadcastSubscriptionChange(workspaceID string, subscription *model.Subscription)
|
2021-09-16 21:31:02 +02:00
|
|
|
HandleClusterEvent(ev mmModel.PluginClusterEvent)
|
|
|
|
}
|
|
|
|
|
2021-08-27 10:59:14 +02:00
|
|
|
type PluginAdapter struct {
|
2021-09-29 18:19:34 +02:00
|
|
|
api plugin.API
|
|
|
|
auth auth.AuthInterface
|
|
|
|
staleThreshold time.Duration
|
2021-08-27 10:59:14 +02:00
|
|
|
|
2021-09-29 18:19:34 +02:00
|
|
|
listenersMU sync.RWMutex
|
|
|
|
listeners map[string]*PluginAdapterClient
|
|
|
|
listenersByUserID map[string][]*PluginAdapterClient
|
|
|
|
|
|
|
|
subscriptionsMU sync.RWMutex
|
2021-08-27 10:59:14 +02:00
|
|
|
listenersByWorkspace map[string][]*PluginAdapterClient
|
|
|
|
listenersByBlock map[string][]*PluginAdapterClient
|
|
|
|
}
|
|
|
|
|
2021-09-29 18:19:34 +02:00
|
|
|
func NewPluginAdapter(api plugin.API, auth auth.AuthInterface) *PluginAdapter {
|
2021-08-27 10:59:14 +02:00
|
|
|
return &PluginAdapter{
|
|
|
|
api: api,
|
|
|
|
auth: auth,
|
2021-09-29 18:19:34 +02:00
|
|
|
staleThreshold: 5 * time.Minute,
|
2021-08-27 10:59:14 +02:00
|
|
|
listeners: make(map[string]*PluginAdapterClient),
|
2021-09-29 18:19:34 +02:00
|
|
|
listenersByUserID: make(map[string][]*PluginAdapterClient),
|
2021-08-27 10:59:14 +02:00
|
|
|
listenersByWorkspace: make(map[string][]*PluginAdapterClient),
|
|
|
|
listenersByBlock: make(map[string][]*PluginAdapterClient),
|
2021-09-29 18:19:34 +02:00
|
|
|
listenersMU: sync.RWMutex{},
|
|
|
|
subscriptionsMU: sync.RWMutex{},
|
2021-08-27 10:59:14 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-09-29 18:19:34 +02:00
|
|
|
func (pa *PluginAdapter) GetListenerByWebConnID(webConnID string) (pac *PluginAdapterClient, ok bool) {
|
|
|
|
pa.listenersMU.RLock()
|
|
|
|
defer pa.listenersMU.RUnlock()
|
|
|
|
|
|
|
|
pac, ok = pa.listeners[webConnID]
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
func (pa *PluginAdapter) GetListenersByUserID(userID string) []*PluginAdapterClient {
|
|
|
|
pa.listenersMU.RLock()
|
|
|
|
defer pa.listenersMU.RUnlock()
|
|
|
|
|
|
|
|
return pa.listenersByUserID[userID]
|
|
|
|
}
|
|
|
|
|
|
|
|
func (pa *PluginAdapter) GetListenersByWorkspace(workspaceID string) []*PluginAdapterClient {
|
|
|
|
pa.subscriptionsMU.RLock()
|
|
|
|
defer pa.subscriptionsMU.RUnlock()
|
|
|
|
|
|
|
|
return pa.listenersByWorkspace[workspaceID]
|
|
|
|
}
|
|
|
|
|
|
|
|
func (pa *PluginAdapter) GetListenersByBlock(blockID string) []*PluginAdapterClient {
|
|
|
|
pa.subscriptionsMU.RLock()
|
|
|
|
defer pa.subscriptionsMU.RUnlock()
|
|
|
|
|
|
|
|
return pa.listenersByBlock[blockID]
|
|
|
|
}
|
|
|
|
|
2021-08-27 10:59:14 +02:00
|
|
|
func (pa *PluginAdapter) addListener(pac *PluginAdapterClient) {
|
2021-09-29 18:19:34 +02:00
|
|
|
pa.listenersMU.Lock()
|
|
|
|
defer pa.listenersMU.Unlock()
|
|
|
|
|
2021-08-27 10:59:14 +02:00
|
|
|
pa.listeners[pac.webConnID] = pac
|
2021-09-29 18:19:34 +02:00
|
|
|
pa.listenersByUserID[pac.userID] = append(pa.listenersByUserID[pac.userID], pac)
|
2021-08-27 10:59:14 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
func (pa *PluginAdapter) removeListener(pac *PluginAdapterClient) {
|
2021-09-29 18:19:34 +02:00
|
|
|
pa.listenersMU.Lock()
|
|
|
|
defer pa.listenersMU.Unlock()
|
2021-08-27 10:59:14 +02:00
|
|
|
|
|
|
|
// workspace subscriptions
|
|
|
|
for _, workspace := range pac.workspaces {
|
|
|
|
pa.removeListenerFromWorkspace(pac, workspace)
|
|
|
|
}
|
|
|
|
|
|
|
|
// block subscriptions
|
|
|
|
for _, block := range pac.blocks {
|
|
|
|
pa.removeListenerFromBlock(pac, block)
|
|
|
|
}
|
|
|
|
|
2021-09-29 18:19:34 +02:00
|
|
|
// user ID list
|
|
|
|
newUserListeners := []*PluginAdapterClient{}
|
|
|
|
for _, listener := range pa.listenersByUserID[pac.userID] {
|
|
|
|
if listener.webConnID != pac.webConnID {
|
|
|
|
newUserListeners = append(newUserListeners, listener)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
pa.listenersByUserID[pac.userID] = newUserListeners
|
|
|
|
|
2021-08-27 10:59:14 +02:00
|
|
|
delete(pa.listeners, pac.webConnID)
|
|
|
|
}
|
|
|
|
|
2021-09-29 18:19:34 +02:00
|
|
|
func (pa *PluginAdapter) removeExpiredForUserID(userID string) {
|
|
|
|
for _, pac := range pa.GetListenersByUserID(userID) {
|
|
|
|
if !pac.isActive() && pac.hasExpired(pa.staleThreshold) {
|
|
|
|
pa.removeListener(pac)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-08-27 10:59:14 +02:00
|
|
|
func (pa *PluginAdapter) removeListenerFromWorkspace(pac *PluginAdapterClient, workspaceID string) {
|
|
|
|
newWorkspaceListeners := []*PluginAdapterClient{}
|
2021-09-29 18:19:34 +02:00
|
|
|
for _, listener := range pa.GetListenersByWorkspace(workspaceID) {
|
2021-08-27 10:59:14 +02:00
|
|
|
if listener.webConnID != pac.webConnID {
|
|
|
|
newWorkspaceListeners = append(newWorkspaceListeners, listener)
|
|
|
|
}
|
|
|
|
}
|
2021-09-29 18:19:34 +02:00
|
|
|
pa.subscriptionsMU.Lock()
|
2021-08-27 10:59:14 +02:00
|
|
|
pa.listenersByWorkspace[workspaceID] = newWorkspaceListeners
|
2021-09-29 18:19:34 +02:00
|
|
|
pa.subscriptionsMU.Unlock()
|
2021-08-27 10:59:14 +02:00
|
|
|
|
2021-09-29 18:19:34 +02:00
|
|
|
pac.unsubscribeFromWorkspace(workspaceID)
|
2021-08-27 10:59:14 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
func (pa *PluginAdapter) removeListenerFromBlock(pac *PluginAdapterClient, blockID string) {
|
|
|
|
newBlockListeners := []*PluginAdapterClient{}
|
2021-09-29 18:19:34 +02:00
|
|
|
for _, listener := range pa.GetListenersByBlock(blockID) {
|
2021-08-27 10:59:14 +02:00
|
|
|
if listener.webConnID != pac.webConnID {
|
|
|
|
newBlockListeners = append(newBlockListeners, listener)
|
|
|
|
}
|
|
|
|
}
|
2021-09-29 18:19:34 +02:00
|
|
|
pa.subscriptionsMU.Lock()
|
2021-08-27 10:59:14 +02:00
|
|
|
pa.listenersByBlock[blockID] = newBlockListeners
|
2021-09-29 18:19:34 +02:00
|
|
|
pa.subscriptionsMU.Unlock()
|
2021-08-27 10:59:14 +02:00
|
|
|
|
2021-09-29 18:19:34 +02:00
|
|
|
pac.unsubscribeFromBlock(blockID)
|
2021-08-27 10:59:14 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
func (pa *PluginAdapter) subscribeListenerToWorkspace(pac *PluginAdapterClient, workspaceID string) {
|
|
|
|
if pac.isSubscribedToWorkspace(workspaceID) {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2021-09-29 18:19:34 +02:00
|
|
|
pa.subscriptionsMU.Lock()
|
2021-08-27 10:59:14 +02:00
|
|
|
pa.listenersByWorkspace[workspaceID] = append(pa.listenersByWorkspace[workspaceID], pac)
|
2021-09-29 18:19:34 +02:00
|
|
|
pa.subscriptionsMU.Unlock()
|
|
|
|
|
|
|
|
pac.subscribeToWorkspace(workspaceID)
|
2021-08-27 10:59:14 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
func (pa *PluginAdapter) unsubscribeListenerFromWorkspace(pac *PluginAdapterClient, workspaceID string) {
|
|
|
|
if !pac.isSubscribedToWorkspace(workspaceID) {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
pa.removeListenerFromWorkspace(pac, workspaceID)
|
|
|
|
}
|
|
|
|
|
2021-09-29 18:19:34 +02:00
|
|
|
func (pa *PluginAdapter) getUserIDsForWorkspace(workspaceID string) []string {
|
|
|
|
userMap := map[string]bool{}
|
|
|
|
for _, pac := range pa.GetListenersByWorkspace(workspaceID) {
|
|
|
|
if pac.isActive() {
|
|
|
|
userMap[pac.userID] = true
|
|
|
|
}
|
|
|
|
}
|
2021-08-27 10:59:14 +02:00
|
|
|
|
2021-09-29 18:19:34 +02:00
|
|
|
userIDs := []string{}
|
|
|
|
for userID := range userMap {
|
|
|
|
userIDs = append(userIDs, userID)
|
|
|
|
}
|
|
|
|
return userIDs
|
|
|
|
}
|
|
|
|
|
|
|
|
//nolint:unused
|
|
|
|
func (pa *PluginAdapter) unsubscribeListenerFromBlocks(pac *PluginAdapterClient, blockIDs []string) {
|
2021-08-27 10:59:14 +02:00
|
|
|
for _, blockID := range blockIDs {
|
|
|
|
if pac.isSubscribedToBlock(blockID) {
|
|
|
|
pa.removeListenerFromBlock(pac, blockID)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func (pa *PluginAdapter) OnWebSocketConnect(webConnID, userID string) {
|
2021-09-29 18:19:34 +02:00
|
|
|
if existingPAC, ok := pa.GetListenerByWebConnID(webConnID); ok {
|
|
|
|
pa.api.LogDebug("inactive connection found for webconn, reusing",
|
|
|
|
"webConnID", webConnID,
|
|
|
|
"userID", userID,
|
|
|
|
)
|
|
|
|
atomic.StoreInt64(&existingPAC.inactiveAt, 0)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
newPAC := &PluginAdapterClient{
|
|
|
|
inactiveAt: 0,
|
2021-08-27 10:59:14 +02:00
|
|
|
webConnID: webConnID,
|
|
|
|
userID: userID,
|
|
|
|
workspaces: []string{},
|
|
|
|
blocks: []string{},
|
|
|
|
}
|
|
|
|
|
2021-09-29 18:19:34 +02:00
|
|
|
pa.addListener(newPAC)
|
|
|
|
pa.removeExpiredForUserID(userID)
|
2021-08-27 10:59:14 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
func (pa *PluginAdapter) OnWebSocketDisconnect(webConnID, userID string) {
|
2021-09-29 18:19:34 +02:00
|
|
|
pac, ok := pa.GetListenerByWebConnID(webConnID)
|
2021-08-27 10:59:14 +02:00
|
|
|
if !ok {
|
2021-10-08 11:59:21 +02:00
|
|
|
pa.api.LogDebug("received a disconnect for an unregistered webconn",
|
2021-08-27 10:59:14 +02:00
|
|
|
"webConnID", webConnID,
|
|
|
|
"userID", userID,
|
|
|
|
)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2021-09-29 18:19:34 +02:00
|
|
|
atomic.StoreInt64(&pac.inactiveAt, mmModel.GetMillis())
|
2021-08-27 10:59:14 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
func commandFromRequest(req *mmModel.WebSocketRequest) (*WebsocketCommand, error) {
|
|
|
|
c := &WebsocketCommand{Action: strings.TrimPrefix(req.Action, websocketMessagePrefix)}
|
|
|
|
|
|
|
|
if workspaceID, ok := req.Data["workspaceId"]; ok {
|
|
|
|
c.WorkspaceID = workspaceID.(string)
|
|
|
|
} else {
|
|
|
|
return nil, errMissingWorkspaceInCommand
|
|
|
|
}
|
|
|
|
|
|
|
|
if readToken, ok := req.Data["readToken"]; ok {
|
|
|
|
c.ReadToken = readToken.(string)
|
|
|
|
}
|
|
|
|
|
|
|
|
if blockIDs, ok := req.Data["blockIds"]; ok {
|
|
|
|
c.BlockIDs = blockIDs.([]string)
|
|
|
|
}
|
|
|
|
|
|
|
|
return c, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (pa *PluginAdapter) WebSocketMessageHasBeenPosted(webConnID, userID string, req *mmModel.WebSocketRequest) {
|
2021-09-29 18:19:34 +02:00
|
|
|
pac, ok := pa.GetListenerByWebConnID(webConnID)
|
2021-08-27 10:59:14 +02:00
|
|
|
if !ok {
|
|
|
|
pa.api.LogError("received a message for an unregistered webconn",
|
|
|
|
"webConnID", webConnID,
|
|
|
|
"userID", userID,
|
|
|
|
"action", req.Action,
|
|
|
|
)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
// only process messages using the plugin actions
|
|
|
|
if !strings.HasPrefix(req.Action, websocketMessagePrefix) {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
command, err := commandFromRequest(req)
|
|
|
|
if err != nil {
|
|
|
|
pa.api.LogError("error getting command from request",
|
|
|
|
"err", err,
|
|
|
|
"action", req.Action,
|
|
|
|
"webConnID", webConnID,
|
|
|
|
"userID", userID,
|
|
|
|
)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
switch command.Action {
|
|
|
|
// The block-related commands are not implemented in the adapter
|
|
|
|
// as there is no such thing as unauthenticated websocket
|
|
|
|
// connections in plugin mode. Only a debug line is logged
|
|
|
|
case websocketActionSubscribeBlocks, websocketActionUnsubscribeBlocks:
|
|
|
|
pa.api.LogDebug(`Command not implemented in plugin mode`,
|
|
|
|
"command", command.Action,
|
|
|
|
"webConnID", webConnID,
|
|
|
|
"userID", userID,
|
|
|
|
"workspaceID", command.WorkspaceID,
|
|
|
|
)
|
|
|
|
|
|
|
|
case websocketActionSubscribeWorkspace:
|
|
|
|
pa.api.LogDebug(`Command: SUBSCRIBE_WORKSPACE`,
|
|
|
|
"webConnID", webConnID,
|
|
|
|
"userID", userID,
|
|
|
|
"workspaceID", command.WorkspaceID,
|
|
|
|
)
|
|
|
|
|
|
|
|
if !pa.auth.DoesUserHaveWorkspaceAccess(userID, command.WorkspaceID) {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
pa.subscribeListenerToWorkspace(pac, command.WorkspaceID)
|
|
|
|
case websocketActionUnsubscribeWorkspace:
|
|
|
|
pa.api.LogDebug(`Command: UNSUBSCRIBE_WORKSPACE`,
|
|
|
|
"webConnID", webConnID,
|
|
|
|
"userID", userID,
|
|
|
|
"workspaceID", command.WorkspaceID,
|
|
|
|
)
|
|
|
|
|
|
|
|
pa.unsubscribeListenerFromWorkspace(pac, command.WorkspaceID)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-09-16 21:31:02 +02:00
|
|
|
func (pa *PluginAdapter) sendMessageToAllSkipCluster(payload map[string]interface{}) {
|
|
|
|
// Empty &mmModel.WebsocketBroadcast will send to all users
|
|
|
|
pa.api.PublishWebSocketEvent(websocketActionUpdateConfig, payload, &mmModel.WebsocketBroadcast{})
|
|
|
|
}
|
|
|
|
|
|
|
|
func (pa *PluginAdapter) sendMessageToAll(payload map[string]interface{}) {
|
|
|
|
go func() {
|
|
|
|
clusterMessage := &ClusterMessage{Payload: payload}
|
|
|
|
pa.sendMessageToCluster("websocket_message", clusterMessage)
|
|
|
|
}()
|
|
|
|
|
|
|
|
pa.sendMessageToAllSkipCluster(payload)
|
|
|
|
}
|
|
|
|
|
|
|
|
func (pa *PluginAdapter) BroadcastConfigChange(pluginConfig model.ClientConfig) {
|
|
|
|
pa.sendMessageToAll(utils.StructToMap(pluginConfig))
|
|
|
|
}
|
|
|
|
|
2021-09-16 12:18:11 +02:00
|
|
|
// sendWorkspaceMessageSkipCluster sends a message to all the users
|
|
|
|
// with a websocket client connected to.
|
2021-12-10 16:46:37 +01:00
|
|
|
func (pa *PluginAdapter) sendWorkspaceMessageSkipCluster(event string, workspaceID string, payload map[string]interface{}) {
|
2021-09-16 12:18:11 +02:00
|
|
|
userIDs := pa.getUserIDsForWorkspace(workspaceID)
|
|
|
|
for _, userID := range userIDs {
|
2021-12-10 16:46:37 +01:00
|
|
|
pa.api.PublishWebSocketEvent(event, payload, &mmModel.WebsocketBroadcast{UserId: userID})
|
2021-09-16 12:18:11 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// sendWorkspaceMessage sends and propagates a message that is aimed
|
|
|
|
// for all the users that are subscribed to a given workspace.
|
2021-12-10 16:46:37 +01:00
|
|
|
func (pa *PluginAdapter) sendWorkspaceMessage(event string, workspaceID string, payload map[string]interface{}) {
|
2021-09-16 12:18:11 +02:00
|
|
|
go func() {
|
|
|
|
clusterMessage := &ClusterMessage{
|
|
|
|
WorkspaceID: workspaceID,
|
|
|
|
Payload: payload,
|
|
|
|
}
|
|
|
|
|
|
|
|
pa.sendMessageToCluster("websocket_message", clusterMessage)
|
|
|
|
}()
|
|
|
|
|
2021-12-10 16:46:37 +01:00
|
|
|
pa.sendWorkspaceMessageSkipCluster(event, workspaceID, payload)
|
2021-09-16 12:18:11 +02:00
|
|
|
}
|
|
|
|
|
2021-08-27 10:59:14 +02:00
|
|
|
func (pa *PluginAdapter) BroadcastBlockChange(workspaceID string, block model.Block) {
|
|
|
|
pa.api.LogInfo("BroadcastingBlockChange",
|
|
|
|
"workspaceID", workspaceID,
|
|
|
|
"blockID", block.ID,
|
|
|
|
)
|
|
|
|
|
|
|
|
message := UpdateMsg{
|
|
|
|
Action: websocketActionUpdateBlock,
|
|
|
|
Block: block,
|
|
|
|
}
|
|
|
|
|
2021-12-10 16:46:37 +01:00
|
|
|
pa.sendWorkspaceMessage(websocketActionUpdateBlock, workspaceID, utils.StructToMap(message))
|
2021-08-27 10:59:14 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
func (pa *PluginAdapter) BroadcastBlockDelete(workspaceID, blockID, parentID string) {
|
2021-10-07 13:51:01 +02:00
|
|
|
now := utils.GetMillis()
|
2021-08-27 10:59:14 +02:00
|
|
|
block := model.Block{}
|
|
|
|
block.ID = blockID
|
|
|
|
block.ParentID = parentID
|
|
|
|
block.UpdateAt = now
|
|
|
|
block.DeleteAt = now
|
2021-09-22 21:57:00 +02:00
|
|
|
block.WorkspaceID = workspaceID
|
2021-08-27 10:59:14 +02:00
|
|
|
|
|
|
|
pa.BroadcastBlockChange(workspaceID, block)
|
|
|
|
}
|
2021-12-10 16:46:37 +01:00
|
|
|
|
|
|
|
func (pa *PluginAdapter) BroadcastSubscriptionChange(workspaceID string, subscription *model.Subscription) {
|
|
|
|
pa.api.LogInfo("BroadcastingSubscriptionChange",
|
|
|
|
"workspaceID", workspaceID,
|
|
|
|
"blockID", subscription.BlockID,
|
|
|
|
"subscriberID", subscription.SubscriberID,
|
|
|
|
)
|
|
|
|
|
|
|
|
message := UpdateSubscription{
|
|
|
|
Action: websocketActionUpdateSubscription,
|
|
|
|
Subscription: subscription,
|
|
|
|
}
|
|
|
|
|
|
|
|
pa.sendWorkspaceMessage(websocketActionUpdateSubscription, workspaceID, utils.StructToMap(message))
|
|
|
|
}
|