MM-27493 Shared channels (MVP) (#17301)
Remote Cluster Service - provides ability for multiple Mattermost cluster instances to create a trusted connection with each other and exchange messages - trusted connections are managed via slash commands (for now) - facilitates features requiring inter-cluster communication, such as Shared Channels Shared Channels Service - provides ability to shared channels between one or more Mattermost cluster instances (using trusted connection) - sharing/unsharing of channels is managed via slash commands (for now)
Этот коммит содержится в:
292
app/slashcommands/command_remote.go
Обычный файл
292
app/slashcommands/command_remote.go
Обычный файл
@@ -0,0 +1,292 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package slashcommands
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v5/app"
|
||||
"github.com/mattermost/mattermost-server/v5/model"
|
||||
"github.com/mattermost/mattermost-server/v5/shared/i18n"
|
||||
)
|
||||
|
||||
const (
|
||||
AvailableRemoteActions = "invite, accept, remove, status"
|
||||
)
|
||||
|
||||
type RemoteProvider struct {
|
||||
}
|
||||
|
||||
const (
|
||||
CommandTriggerRemote = "remote"
|
||||
)
|
||||
|
||||
func init() {
|
||||
app.RegisterCommandProvider(&RemoteProvider{})
|
||||
}
|
||||
|
||||
func (rp *RemoteProvider) GetTrigger() string {
|
||||
return CommandTriggerRemote
|
||||
}
|
||||
|
||||
func (rp *RemoteProvider) GetCommand(a *app.App, T i18n.TranslateFunc) *model.Command {
|
||||
|
||||
remote := model.NewAutocompleteData(rp.GetTrigger(), "[action]", T("api.command_remote.remote_add_remove.help", map[string]interface{}{"Actions": AvailableRemoteActions}))
|
||||
|
||||
invite := model.NewAutocompleteData("invite", "", T("api.command_remote.invite.help"))
|
||||
invite.AddNamedTextArgument("password", T("api.command_remote.invite_password.help"), T("api.command_remote.invite_password.hint"), "", true)
|
||||
invite.AddNamedTextArgument("name", T("api.command_remote.name.help"), T("api.command_remote.name.hint"), "", true)
|
||||
|
||||
accept := model.NewAutocompleteData("accept", "", T("api.command_remote.accept.help"))
|
||||
accept.AddNamedTextArgument("password", T("api.command_remote.invite_password.help"), T("api.command_remote.invite_password.hint"), "", true)
|
||||
accept.AddNamedTextArgument("name", T("api.command_remote.name.help"), T("api.command_remote.name.hint"), "", true)
|
||||
accept.AddNamedTextArgument("invite", T("api.command_remote.invitation.help"), T("api.command_remote.invitation.hint"), "", true)
|
||||
|
||||
remove := model.NewAutocompleteData("remove", "", T("api.command_remote.remove.help"))
|
||||
remove.AddNamedDynamicListArgument("remoteId", T("api.command_remote.remove_remote_id.help"), "builtin:remote", true)
|
||||
|
||||
status := model.NewAutocompleteData("status", "", T("api.command_remote.status.help"))
|
||||
|
||||
remote.AddCommand(invite)
|
||||
remote.AddCommand(accept)
|
||||
remote.AddCommand(remove)
|
||||
remote.AddCommand(status)
|
||||
|
||||
return &model.Command{
|
||||
Trigger: rp.GetTrigger(),
|
||||
AutoComplete: true,
|
||||
AutoCompleteDesc: T("api.command_remote.desc"),
|
||||
AutoCompleteHint: T("api.command_remote.hint"),
|
||||
DisplayName: T("api.command_remote.name"),
|
||||
AutocompleteData: remote,
|
||||
}
|
||||
}
|
||||
|
||||
func (rp *RemoteProvider) DoCommand(a *app.App, args *model.CommandArgs, message string) *model.CommandResponse {
|
||||
if !a.HasPermissionTo(args.UserId, model.PERMISSION_MANAGE_SHARED_CHANNELS) {
|
||||
return responsef(args.T("api.command_remote.permission_required", map[string]interface{}{"Permission": "manage_shared_channels"}))
|
||||
}
|
||||
|
||||
margs := parseNamedArgs(args.Command)
|
||||
action, ok := margs[ActionKey]
|
||||
if !ok {
|
||||
return responsef(args.T("api.command_remote.missing_command", map[string]interface{}{"Actions": AvailableRemoteActions}))
|
||||
}
|
||||
|
||||
switch action {
|
||||
case "invite":
|
||||
return rp.doInvite(a, args, margs)
|
||||
case "accept":
|
||||
return rp.doAccept(a, args, margs)
|
||||
case "remove":
|
||||
return rp.doRemove(a, args, margs)
|
||||
case "status":
|
||||
return rp.doStatus(a, args, margs)
|
||||
}
|
||||
|
||||
return responsef(args.T("api.command_remote.unknown_action", map[string]interface{}{"Action": action}))
|
||||
}
|
||||
|
||||
func (rp *RemoteProvider) GetAutoCompleteListItems(a *app.App, commandArgs *model.CommandArgs, arg *model.AutocompleteArg, parsed, toBeParsed string) ([]model.AutocompleteListItem, error) {
|
||||
if !a.HasPermissionTo(commandArgs.UserId, model.PERMISSION_MANAGE_SHARED_CHANNELS) {
|
||||
return nil, errors.New("You require `manage_shared_channels` permission to manage remote clusters.")
|
||||
}
|
||||
|
||||
if arg.Name == "remoteId" && strings.Contains(parsed, " remove ") {
|
||||
return getRemoteClusterAutocompleteListItems(a, true)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("`%s` is not a dynamic argument", arg.Name)
|
||||
}
|
||||
|
||||
// doInvite creates and displays an encrypted invite that can be used by a remote site to establish a simple trust.
|
||||
func (rp *RemoteProvider) doInvite(a *app.App, args *model.CommandArgs, margs map[string]string) *model.CommandResponse {
|
||||
password := margs["password"]
|
||||
if password == "" {
|
||||
return responsef(args.T("api.command_remote.missing_empty", map[string]interface{}{"Arg": "password"}))
|
||||
}
|
||||
|
||||
name := margs["name"]
|
||||
if name == "" {
|
||||
return responsef(args.T("api.command_remote.missing_empty", map[string]interface{}{"Arg": "name"}))
|
||||
}
|
||||
|
||||
url := a.GetSiteURL()
|
||||
if url == "" {
|
||||
return responsef(args.T("api.command_remote.site_url_not_set"))
|
||||
}
|
||||
|
||||
rc := &model.RemoteCluster{
|
||||
DisplayName: name,
|
||||
Token: model.NewId(),
|
||||
CreatorId: args.UserId,
|
||||
}
|
||||
|
||||
rcSaved, appErr := a.AddRemoteCluster(rc)
|
||||
if appErr != nil {
|
||||
return responsef(args.T("api.command_remote.add_remote.error", map[string]interface{}{"Error": appErr.Error()}))
|
||||
}
|
||||
|
||||
// Display the encrypted invitation
|
||||
invite := &model.RemoteClusterInvite{
|
||||
RemoteId: rcSaved.RemoteId,
|
||||
RemoteTeamId: args.TeamId,
|
||||
SiteURL: url,
|
||||
Token: rcSaved.Token,
|
||||
}
|
||||
encrypted, err := invite.Encrypt(password)
|
||||
if err != nil {
|
||||
return responsef(args.T("api.command_remote.encrypt_invitation.error", map[string]interface{}{"Error": err.Error()}))
|
||||
}
|
||||
encoded := base64.URLEncoding.EncodeToString(encrypted)
|
||||
|
||||
return responsef("##### " + args.T("api.command_remote.invitation_created") + "\n" +
|
||||
args.T("api.command_remote.invite_summary", map[string]interface{}{"Command": "/remote accept", "Invitation": encoded, "SiteURL": invite.SiteURL}))
|
||||
}
|
||||
|
||||
// doAccept accepts an invitation generated by a remote site.
|
||||
func (rp *RemoteProvider) doAccept(a *app.App, args *model.CommandArgs, margs map[string]string) *model.CommandResponse {
|
||||
password := margs["password"]
|
||||
if password == "" {
|
||||
return responsef(args.T("api.command_remote.missing_empty", map[string]interface{}{"Arg": "password"}))
|
||||
}
|
||||
|
||||
name := margs["name"]
|
||||
if name == "" {
|
||||
return responsef(args.T("api.command_remote.missing_empty", map[string]interface{}{"Arg": "name"}))
|
||||
}
|
||||
|
||||
blob := margs["invite"]
|
||||
if blob == "" {
|
||||
return responsef(args.T("api.command_remote.missing_empty", map[string]interface{}{"Arg": "invite"}))
|
||||
}
|
||||
|
||||
// invite is encoded as base64 and encrypted
|
||||
decoded, err := base64.URLEncoding.DecodeString(blob)
|
||||
if err != nil {
|
||||
return responsef(args.T("api.command_remote.decode_invitation.error", map[string]interface{}{"Error": err.Error()}))
|
||||
}
|
||||
invite := &model.RemoteClusterInvite{}
|
||||
err = invite.Decrypt(decoded, password)
|
||||
if err != nil {
|
||||
return responsef(args.T("api.command_remote.incorrect_password.error", map[string]interface{}{"Error": err.Error()}))
|
||||
}
|
||||
|
||||
rcs, _ := a.GetRemoteClusterService()
|
||||
if rcs == nil {
|
||||
return responsef(args.T("api.command_remote.service_not_enabled"))
|
||||
}
|
||||
|
||||
url := a.GetSiteURL()
|
||||
if url == "" {
|
||||
return responsef(args.T("api.command_remote.site_url_not_set"))
|
||||
}
|
||||
|
||||
rc, err := rcs.AcceptInvitation(invite, name, args.UserId, args.TeamId, url)
|
||||
if err != nil {
|
||||
return responsef(args.T("api.command_remote.accept_invitation.error", map[string]interface{}{"Error": err.Error()}))
|
||||
}
|
||||
|
||||
return responsef("##### " + args.T("api.command_remote.accept_invitation", map[string]interface{}{"SiteURL": rc.SiteURL}))
|
||||
}
|
||||
|
||||
// doRemove removes a remote cluster from the database, effectively revoking the trust relationship.
|
||||
func (rp *RemoteProvider) doRemove(a *app.App, args *model.CommandArgs, margs map[string]string) *model.CommandResponse {
|
||||
id, ok := margs["remoteId"]
|
||||
if !ok {
|
||||
return responsef(args.T("api.command_remote.missing_empty", map[string]interface{}{"Arg": "remoteId"}))
|
||||
}
|
||||
|
||||
deleted, err := a.DeleteRemoteCluster(id)
|
||||
if err != nil {
|
||||
responsef(args.T("api.command_remote.remove_remote.error", map[string]interface{}{"Error": err.Error()}))
|
||||
}
|
||||
|
||||
result := "removed"
|
||||
if !deleted {
|
||||
result = "**NOT FOUND**"
|
||||
}
|
||||
return responsef("##### " + args.T("api.command_remote.cluster_removed", map[string]interface{}{"RemoteId": id, "Result": result}))
|
||||
}
|
||||
|
||||
// doStatus displays connection status for all remote clusters.
|
||||
func (rp *RemoteProvider) doStatus(a *app.App, args *model.CommandArgs, _ map[string]string) *model.CommandResponse {
|
||||
list, err := a.GetAllRemoteClusters(model.RemoteClusterQueryFilter{})
|
||||
if err != nil {
|
||||
responsef(args.T("api.command_remote.fetch_status.error", map[string]interface{}{"Error": err.Error()}))
|
||||
}
|
||||
|
||||
if len(list) == 0 {
|
||||
return responsef("** " + args.T("api.command_remote.remotes_not_found") + " **")
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
fmt.Fprintf(&sb, args.T("api.command_remote.remote_table_header")+"| \n")
|
||||
fmt.Fprintf(&sb, "| ---- | -------- | ---------- | :-------------: | :----: | ---------- |\n")
|
||||
|
||||
for _, rc := range list {
|
||||
accepted := ":white_check_mark:"
|
||||
if rc.SiteURL == "" {
|
||||
accepted = ":x:"
|
||||
}
|
||||
|
||||
online := ":white_check_mark:"
|
||||
if !isOnline(rc.LastPingAt) {
|
||||
online = ":skull_and_crossbones:"
|
||||
}
|
||||
|
||||
lastPing := formatTimestamp(model.GetTimeForMillis(rc.LastPingAt))
|
||||
|
||||
fmt.Fprintf(&sb, "| %s | %s | %s | %s | %s | %s |\n", rc.DisplayName, rc.SiteURL, rc.RemoteId, accepted, online, lastPing)
|
||||
}
|
||||
return responsef(sb.String())
|
||||
}
|
||||
|
||||
func isOnline(lastPing int64) bool {
|
||||
return lastPing > model.GetMillis()-model.RemoteOfflineAfterMillis
|
||||
}
|
||||
|
||||
func getRemoteClusterAutocompleteListItems(a *app.App, includeOffline bool) ([]model.AutocompleteListItem, error) {
|
||||
filter := model.RemoteClusterQueryFilter{
|
||||
ExcludeOffline: !includeOffline,
|
||||
}
|
||||
clusters, err := a.GetAllRemoteClusters(filter)
|
||||
if err != nil || len(clusters) == 0 {
|
||||
return []model.AutocompleteListItem{}, nil
|
||||
}
|
||||
|
||||
list := make([]model.AutocompleteListItem, 0, len(clusters))
|
||||
|
||||
for _, rc := range clusters {
|
||||
item := model.AutocompleteListItem{
|
||||
Item: rc.RemoteId,
|
||||
HelpText: fmt.Sprintf("%s (%s)", rc.DisplayName, rc.SiteURL)}
|
||||
list = append(list, item)
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func getRemoteClusterAutocompleteListItemsNotInChannel(a *app.App, channelId string, includeOffline bool) ([]model.AutocompleteListItem, error) {
|
||||
filter := model.RemoteClusterQueryFilter{
|
||||
ExcludeOffline: !includeOffline,
|
||||
NotInChannel: channelId,
|
||||
}
|
||||
all, err := a.GetAllRemoteClusters(filter)
|
||||
if err != nil || len(all) == 0 {
|
||||
return []model.AutocompleteListItem{}, nil
|
||||
}
|
||||
|
||||
list := make([]model.AutocompleteListItem, 0, len(all))
|
||||
|
||||
for _, rc := range all {
|
||||
item := model.AutocompleteListItem{
|
||||
Item: rc.RemoteId,
|
||||
HelpText: fmt.Sprintf("%s (%s)", rc.DisplayName, rc.SiteURL)}
|
||||
list = append(list, item)
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
347
app/slashcommands/command_share.go
Обычный файл
347
app/slashcommands/command_share.go
Обычный файл
@@ -0,0 +1,347 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package slashcommands
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v5/app"
|
||||
"github.com/mattermost/mattermost-server/v5/model"
|
||||
"github.com/mattermost/mattermost-server/v5/shared/i18n"
|
||||
)
|
||||
|
||||
type ShareProvider struct {
|
||||
}
|
||||
|
||||
const (
|
||||
CommandTriggerShare = "share"
|
||||
AvailableShareActions = "share_channel, unshare_channel, invite_remove, uninvite_remote, status"
|
||||
)
|
||||
|
||||
func init() {
|
||||
app.RegisterCommandProvider(&ShareProvider{})
|
||||
}
|
||||
|
||||
func (sp *ShareProvider) GetTrigger() string {
|
||||
return CommandTriggerShare
|
||||
}
|
||||
|
||||
func (sp *ShareProvider) GetCommand(a *app.App, T i18n.TranslateFunc) *model.Command {
|
||||
share := model.NewAutocompleteData(CommandTriggerShare, "[action]", T("api.command_share.available_actions", map[string]interface{}{"Actions": AvailableShareActions}))
|
||||
|
||||
shareChannel := model.NewAutocompleteData("share_channel", "", T("api.command_share.share_current"))
|
||||
shareChannel.AddNamedTextArgument("readonly", T("api.command_share.share_read_only.help"), T("api.command_share.share_read_only.hint"), "Y|N|y|n", false)
|
||||
shareChannel.AddNamedTextArgument("name", T("api.command_share.channel_name.help"), T("api.command_share.channel_name.hint"), "", false)
|
||||
shareChannel.AddNamedTextArgument("displayname", T("api.command_share.channel_display_name.help"), T("api.command_share.channel_display_name.hint"), "", false)
|
||||
shareChannel.AddNamedTextArgument("purpose", T("api.command_share.channel_purpose.help"), T("api.command_share.channel_purpose.hint"), "", false)
|
||||
shareChannel.AddNamedTextArgument("header", T("api.command_share.channel_header.help"), T("api.command_share.channel_header.hint"), "", false)
|
||||
|
||||
unshareChannel := model.NewAutocompleteData("unshare_channel", "", T("api.command_share.unshare_channel.help"))
|
||||
unshareChannel.AddNamedTextArgument("are_you_sure", T("api.command_share.unshare_confirmation.help"), T("api.command_share.unshare_confirmation.hint"), "Y|N|y|n", true)
|
||||
|
||||
inviteRemote := model.NewAutocompleteData("invite_remote", "", T("api.command_share.invite_remote.help"))
|
||||
inviteRemote.AddNamedDynamicListArgument("remoteId", T("api.command_share.remote_id.help"), "builtin:share", true)
|
||||
inviteRemote.AddNamedTextArgument("description", T("api.command_share.description_invite.help"), T("api.command_share.description_invite.hint"), "", false)
|
||||
|
||||
unInviteRemote := model.NewAutocompleteData("uninvite_remote", "", T("api.command_share.uninvite_remote.help"))
|
||||
unInviteRemote.AddNamedDynamicListArgument("remoteId", T("api.command_share.uninvite_remote_id.help"), "builtin:share", true)
|
||||
|
||||
status := model.NewAutocompleteData("status", "", T("api.command_share.channel_status.help"))
|
||||
|
||||
share.AddCommand(shareChannel)
|
||||
share.AddCommand(unshareChannel)
|
||||
share.AddCommand(inviteRemote)
|
||||
share.AddCommand(unInviteRemote)
|
||||
share.AddCommand(status)
|
||||
|
||||
return &model.Command{
|
||||
Trigger: CommandTriggerShare,
|
||||
AutoComplete: true,
|
||||
AutoCompleteDesc: T("api.command_share.desc"),
|
||||
AutoCompleteHint: T("api.command_share.hint"),
|
||||
DisplayName: T("api.command_share.name"),
|
||||
AutocompleteData: share,
|
||||
}
|
||||
}
|
||||
|
||||
func (sp *ShareProvider) GetAutoCompleteListItems(a *app.App, commandArgs *model.CommandArgs, arg *model.AutocompleteArg, parsed, toBeParsed string) ([]model.AutocompleteListItem, error) {
|
||||
switch {
|
||||
case strings.Contains(parsed, " share_channel "):
|
||||
|
||||
return sp.getAutoCompleteShareChannel(a, commandArgs, arg)
|
||||
|
||||
case strings.Contains(parsed, " invite_remote "):
|
||||
|
||||
return sp.getAutoCompleteInviteRemote(a, commandArgs, arg)
|
||||
|
||||
case strings.Contains(parsed, " uninvite_remote "):
|
||||
|
||||
return sp.getAutoCompleteUnInviteRemote(a, commandArgs, arg)
|
||||
|
||||
}
|
||||
return nil, errors.New("invalid action")
|
||||
}
|
||||
|
||||
func (sp *ShareProvider) getAutoCompleteShareChannel(a *app.App, commandArgs *model.CommandArgs, arg *model.AutocompleteArg) ([]model.AutocompleteListItem, error) {
|
||||
channel, err := a.GetChannel(commandArgs.ChannelId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var item model.AutocompleteListItem
|
||||
|
||||
switch arg.Name {
|
||||
case "name":
|
||||
item = model.AutocompleteListItem{
|
||||
Item: channel.Name,
|
||||
HelpText: channel.DisplayName,
|
||||
}
|
||||
case "displayname":
|
||||
item = model.AutocompleteListItem{
|
||||
Item: channel.DisplayName,
|
||||
HelpText: channel.Name,
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("%s not a dynamic argument", arg.Name)
|
||||
}
|
||||
return []model.AutocompleteListItem{item}, nil
|
||||
}
|
||||
|
||||
func (sp *ShareProvider) getAutoCompleteInviteRemote(a *app.App, commandArgs *model.CommandArgs, arg *model.AutocompleteArg) ([]model.AutocompleteListItem, error) {
|
||||
switch arg.Name {
|
||||
case "remoteId":
|
||||
return getRemoteClusterAutocompleteListItemsNotInChannel(a, commandArgs.ChannelId, true)
|
||||
default:
|
||||
return nil, fmt.Errorf("%s not a dynamic argument", arg.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func (sp *ShareProvider) getAutoCompleteUnInviteRemote(a *app.App, _ *model.CommandArgs, arg *model.AutocompleteArg) ([]model.AutocompleteListItem, error) {
|
||||
switch arg.Name {
|
||||
case "remoteId":
|
||||
return getRemoteClusterAutocompleteListItems(a, true)
|
||||
default:
|
||||
return nil, fmt.Errorf("%s not a dynamic argument", arg.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func (sp *ShareProvider) DoCommand(a *app.App, args *model.CommandArgs, message string) *model.CommandResponse {
|
||||
if !a.HasPermissionTo(args.UserId, model.PERMISSION_MANAGE_SHARED_CHANNELS) {
|
||||
return responsef(args.T("api.command_share.permission_required", map[string]interface{}{"Permission": "manage_shared_channels"}))
|
||||
}
|
||||
|
||||
if a.Srv().GetSharedChannelSyncService() == nil {
|
||||
return responsef(args.T("api.command_share.service_disabled"))
|
||||
}
|
||||
|
||||
if a.Srv().GetRemoteClusterService() == nil {
|
||||
return responsef(args.T("api.command_remote.service_disabled"))
|
||||
}
|
||||
|
||||
margs := parseNamedArgs(args.Command)
|
||||
action, ok := margs[ActionKey]
|
||||
if !ok {
|
||||
return responsef(args.T("api.command_share.missing_action", map[string]interface{}{"Actions": AvailableShareActions}))
|
||||
}
|
||||
|
||||
switch action {
|
||||
case "share_channel":
|
||||
return sp.doShareChannel(a, args, margs)
|
||||
case "unshare_channel":
|
||||
return sp.doUnshareChannel(a, args, margs)
|
||||
case "invite_remote":
|
||||
return sp.doInviteRemote(a, args, margs)
|
||||
case "uninvite_remote":
|
||||
return sp.doUninviteRemote(a, args, margs)
|
||||
case "status":
|
||||
return sp.doStatus(a, args, margs)
|
||||
}
|
||||
return responsef(args.T("api.command_share.unknown_action", map[string]interface{}{"Action": action, "Actions": AvailableShareActions}))
|
||||
}
|
||||
|
||||
func (sp *ShareProvider) doShareChannel(a *app.App, args *model.CommandArgs, margs map[string]string) *model.CommandResponse {
|
||||
// check that channel exists.
|
||||
channel, errApp := a.GetChannel(args.ChannelId)
|
||||
if errApp != nil {
|
||||
return responsef(args.T("api.command_share.share_channel.error", map[string]interface{}{"Error": errApp.Error()}))
|
||||
}
|
||||
|
||||
if name := margs["name"]; name == "" {
|
||||
margs["name"] = channel.Name
|
||||
}
|
||||
if name := margs["displayname"]; name == "" {
|
||||
margs["displayname"] = channel.DisplayName
|
||||
}
|
||||
if name := margs["purpose"]; name == "" {
|
||||
margs["purpose"] = channel.Purpose
|
||||
}
|
||||
if name := margs["header"]; name == "" {
|
||||
margs["header"] = channel.Header
|
||||
}
|
||||
if _, ok := margs["readonly"]; !ok {
|
||||
margs["readonly"] = "N"
|
||||
}
|
||||
|
||||
readonly, err := parseBool(margs["readonly"])
|
||||
if err != nil {
|
||||
return responsef(args.T("api.command_share.invalid_value.error", map[string]interface{}{"Arg": "readonly", "Error": err.Error()}))
|
||||
}
|
||||
|
||||
sc := &model.SharedChannel{
|
||||
ChannelId: args.ChannelId,
|
||||
TeamId: args.TeamId,
|
||||
Home: true,
|
||||
ReadOnly: readonly,
|
||||
ShareName: margs["name"],
|
||||
ShareDisplayName: margs["displayname"],
|
||||
SharePurpose: margs["purpose"],
|
||||
ShareHeader: margs["header"],
|
||||
CreatorId: args.UserId,
|
||||
}
|
||||
|
||||
if _, err := a.SaveSharedChannel(sc); err != nil {
|
||||
return responsef(args.T("api.command_share.share_channel.error", map[string]interface{}{"Error": err.Error()}))
|
||||
}
|
||||
|
||||
notifyClientsForChannelUpdate(a, sc)
|
||||
|
||||
return responsef("##### " + args.T("api.command_share.channel_shared"))
|
||||
}
|
||||
|
||||
func (sp *ShareProvider) doUnshareChannel(a *app.App, args *model.CommandArgs, margs map[string]string) *model.CommandResponse {
|
||||
if _, ok := margs["are_you_sure"]; !ok {
|
||||
margs["are_you_sure"] = "N"
|
||||
}
|
||||
|
||||
sure, err := parseBool(margs["are_you_sure"])
|
||||
if err != nil || !sure {
|
||||
return responsef(args.T("api.command_share.shared_channel_not_deleted", map[string]interface{}{"Arg": "are_you_sure", "Expected": "Y"}))
|
||||
}
|
||||
|
||||
sc, appErr := a.GetSharedChannel(args.ChannelId)
|
||||
if appErr != nil {
|
||||
return responsef(args.T("api.command_share.shared_channel_unshare.error", map[string]interface{}{"Error": appErr.Error()}))
|
||||
}
|
||||
|
||||
deleted, err := a.DeleteSharedChannel(args.ChannelId)
|
||||
if err != nil {
|
||||
return responsef(args.T("api.command_share.shared_channel_unshare.error", map[string]interface{}{"Error": err.Error()}))
|
||||
}
|
||||
if !deleted {
|
||||
return responsef(args.T("api.command_share.not_shared_channel_unshare"))
|
||||
}
|
||||
|
||||
notifyClientsForChannelUpdate(a, sc)
|
||||
|
||||
return responsef("##### " + args.T("api.command_share.shared_channel_unavailable"))
|
||||
}
|
||||
|
||||
func (sp *ShareProvider) doInviteRemote(a *app.App, args *model.CommandArgs, margs map[string]string) (resp *model.CommandResponse) {
|
||||
remoteId, ok := margs["remoteId"]
|
||||
if !ok || remoteId == "" {
|
||||
return responsef(args.T("api.command_share.must_specify_valid_remote"))
|
||||
}
|
||||
|
||||
hasRemote, err := a.HasRemote(args.ChannelId, remoteId)
|
||||
if err != nil {
|
||||
return responsef(args.T("api.command_share.fetch_remote.error", map[string]interface{}{"Error": err.Error()}))
|
||||
}
|
||||
if hasRemote {
|
||||
return responsef(args.T("api.command_share.remote_already_invited"))
|
||||
}
|
||||
|
||||
// Check if channel is shared or not.
|
||||
hasChan, err := a.HasSharedChannel(args.ChannelId)
|
||||
if err != nil {
|
||||
return responsef(args.T("api.command_share.check_channel_exist.error", map[string]interface{}{"Error": err.Error()}))
|
||||
}
|
||||
if !hasChan {
|
||||
// If it doesn't exist, then create it.
|
||||
resp2 := sp.doShareChannel(a, args, margs)
|
||||
// We modify the outgoing response by prepending the text
|
||||
// from the shareChannel response.
|
||||
defer func() {
|
||||
resp.Text = resp2.Text + "\n" + resp.Text
|
||||
}()
|
||||
}
|
||||
|
||||
// don't allow invitation to shared channel originating from remote.
|
||||
// (also blocks cyclic invitations)
|
||||
if err := a.CheckCanInviteToSharedChannel(args.ChannelId); err != nil {
|
||||
return responsef(args.T("api.command_share.channel_invite_not_home.error"))
|
||||
}
|
||||
|
||||
rc, appErr := a.GetRemoteCluster(remoteId)
|
||||
if appErr != nil {
|
||||
return responsef(args.T("api.command_share.remote_id_invalid.error", map[string]interface{}{"Error": appErr.Error()}))
|
||||
}
|
||||
|
||||
channel, errApp := a.GetChannel(args.ChannelId)
|
||||
if errApp != nil {
|
||||
return responsef(args.T("api.command_share.channel_invite.error", map[string]interface{}{"Name": rc.DisplayName, "Error": errApp.Error()}))
|
||||
}
|
||||
// send channel invite to remote cluster
|
||||
if err := a.Srv().GetSharedChannelSyncService().SendChannelInvite(channel, args.UserId, margs["description"], rc); err != nil {
|
||||
return responsef(args.T("api.command_share.channel_invite.error", map[string]interface{}{"Name": rc.DisplayName, "Error": err.Error()}))
|
||||
}
|
||||
|
||||
return responsef("##### " + args.T("api.command_share.invitation_sent", map[string]interface{}{"Name": rc.DisplayName, "SiteURL": rc.SiteURL}))
|
||||
}
|
||||
|
||||
func (sp *ShareProvider) doUninviteRemote(a *app.App, args *model.CommandArgs, margs map[string]string) *model.CommandResponse {
|
||||
remoteId, ok := margs["remoteId"]
|
||||
if !ok || remoteId == "" {
|
||||
return responsef(args.T("api.command_share.remote_not_valid"))
|
||||
}
|
||||
|
||||
scr, err := a.GetSharedChannelRemoteByIds(args.ChannelId, remoteId)
|
||||
if err != nil || scr.ChannelId != args.ChannelId {
|
||||
return responsef(args.T("api.command_share.channel_remote_id_not_exists", map[string]interface{}{"RemoteId": remoteId}))
|
||||
}
|
||||
|
||||
deleted, err := a.DeleteSharedChannelRemote(scr.Id)
|
||||
if err != nil || !deleted {
|
||||
return responsef(args.T("api.command_share.could_not_uninvite.error", map[string]interface{}{"RemoteId": remoteId, "Error": err.Error()}))
|
||||
}
|
||||
return responsef("##### " + args.T("api.command_share.remote_uninvited", map[string]interface{}{"RemoteId": remoteId}))
|
||||
}
|
||||
|
||||
func (sp *ShareProvider) doStatus(a *app.App, args *model.CommandArgs, _ map[string]string) *model.CommandResponse {
|
||||
statuses, err := a.GetSharedChannelRemotesStatus(args.ChannelId)
|
||||
if err != nil {
|
||||
return responsef(args.T("api.command_share.fetch_remote_status.error", map[string]interface{}{"Error": err.Error()}))
|
||||
}
|
||||
if len(statuses) == 0 {
|
||||
return responsef(args.T("api.command_share.no_remote_invited"))
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
|
||||
fmt.Fprintf(&sb, args.T("api.command_share.channel_status_id", map[string]interface{}{"ChannelId": statuses[0].ChannelId})+"\n\n")
|
||||
|
||||
fmt.Fprintf(&sb, args.T("api.command_share.remote_table_header")+" \n")
|
||||
fmt.Fprintf(&sb, "| ------ | ------- | ----------- | -------- | -------------- | ------ | --------- | \n")
|
||||
|
||||
for _, status := range statuses {
|
||||
online := ":white_check_mark:"
|
||||
if !isOnline(status.LastPingAt) {
|
||||
online = ":skull_and_crossbones:"
|
||||
}
|
||||
|
||||
lastSync := formatTimestamp(model.GetTimeForMillis(status.NextSyncAt))
|
||||
|
||||
fmt.Fprintf(&sb, "| %s | %s | %s | %t | %t | %s | %s |\n",
|
||||
status.DisplayName, status.SiteURL, status.Description,
|
||||
status.ReadOnly, status.IsInviteAccepted, online, lastSync)
|
||||
}
|
||||
return responsef(sb.String())
|
||||
}
|
||||
|
||||
func notifyClientsForChannelUpdate(a *app.App, sharedChannel *model.SharedChannel) {
|
||||
messageWs := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_CHANNEL_CONVERTED, sharedChannel.TeamId, "", "", nil)
|
||||
messageWs.Add("channel_id", sharedChannel.ChannelId)
|
||||
a.Publish(messageWs)
|
||||
}
|
||||
92
app/slashcommands/command_share_test.go
Обычный файл
92
app/slashcommands/command_share_test.go
Обычный файл
@@ -0,0 +1,92 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package slashcommands
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v5/testlib"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v5/app"
|
||||
"github.com/mattermost/mattermost-server/v5/services/remotecluster"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v5/model"
|
||||
)
|
||||
|
||||
func TestShareProviderDoCommand(t *testing.T) {
|
||||
t.Run("share command sends a websocket channel converted event", func(t *testing.T) {
|
||||
th := setup(t).initBasic()
|
||||
defer th.tearDown()
|
||||
|
||||
th.addPermissionToRole(model.PERMISSION_MANAGE_SHARED_CHANNELS.Id, th.BasicUser.Roles)
|
||||
|
||||
mockSyncService := app.NewMockSharedChannelService(nil)
|
||||
th.Server.SetSharedChannelSyncService(mockSyncService)
|
||||
mockRemoteCluster, err := remotecluster.NewRemoteClusterService(th.Server)
|
||||
require.NoError(t, err)
|
||||
|
||||
th.Server.SetRemoteClusterService(mockRemoteCluster)
|
||||
testCluster := &testlib.FakeClusterInterface{}
|
||||
th.Server.Cluster = testCluster
|
||||
|
||||
commandProvider := ShareProvider{}
|
||||
channel := th.CreateChannel(th.BasicTeam, WithShared(false))
|
||||
args := &model.CommandArgs{
|
||||
T: func(s string, args ...interface{}) string { return s },
|
||||
ChannelId: channel.Id,
|
||||
UserId: th.BasicUser.Id,
|
||||
TeamId: th.BasicTeam.Id,
|
||||
Command: "/share share_channel",
|
||||
}
|
||||
|
||||
response := commandProvider.DoCommand(th.App, args, "")
|
||||
require.Equal(t, "##### "+args.T("api.command_share.channel_shared"), response.Text)
|
||||
|
||||
channelConvertedMessages := testCluster.SelectMessages(func(msg *model.ClusterMessage) bool {
|
||||
event := model.WebSocketEventFromJson(strings.NewReader(msg.Data))
|
||||
return event != nil && event.EventType() == model.WEBSOCKET_EVENT_CHANNEL_CONVERTED
|
||||
})
|
||||
assert.Len(t, channelConvertedMessages, 1)
|
||||
})
|
||||
|
||||
t.Run("unshare command sends a websocket channel converted event", func(t *testing.T) {
|
||||
th := setup(t).initBasic()
|
||||
defer th.tearDown()
|
||||
|
||||
th.addPermissionToRole(model.PERMISSION_MANAGE_SHARED_CHANNELS.Id, th.BasicUser.Roles)
|
||||
|
||||
mockSyncService := app.NewMockSharedChannelService(nil)
|
||||
th.Server.SetSharedChannelSyncService(mockSyncService)
|
||||
mockRemoteCluster, err := remotecluster.NewRemoteClusterService(th.Server)
|
||||
require.NoError(t, err)
|
||||
|
||||
th.Server.SetRemoteClusterService(mockRemoteCluster)
|
||||
testCluster := &testlib.FakeClusterInterface{}
|
||||
th.Server.Cluster = testCluster
|
||||
|
||||
commandProvider := ShareProvider{}
|
||||
channel := th.CreateChannel(th.BasicTeam, WithShared(true))
|
||||
args := &model.CommandArgs{
|
||||
T: func(s string, args ...interface{}) string { return s },
|
||||
ChannelId: channel.Id,
|
||||
UserId: th.BasicUser.Id,
|
||||
TeamId: th.BasicTeam.Id,
|
||||
Command: "/share unshare_channel --are_you_sure Y",
|
||||
}
|
||||
|
||||
response := commandProvider.DoCommand(th.App, args, "")
|
||||
require.Equal(t, "##### "+args.T("api.command_share.shared_channel_unavailable"), response.Text)
|
||||
|
||||
channelConvertedMessages := testCluster.SelectMessages(func(msg *model.ClusterMessage) bool {
|
||||
event := model.WebSocketEventFromJson(strings.NewReader(msg.Data))
|
||||
return event != nil && event.EventType() == model.WEBSOCKET_EVENT_CHANNEL_CONVERTED
|
||||
})
|
||||
require.Len(t, channelConvertedMessages, 1)
|
||||
})
|
||||
}
|
||||
@@ -226,15 +226,23 @@ func (th *TestHelper) createUserOrGuest(guest bool) *model.User {
|
||||
return user
|
||||
}
|
||||
|
||||
func (th *TestHelper) CreateChannel(team *model.Team) *model.Channel {
|
||||
return th.createChannel(team, model.CHANNEL_OPEN)
|
||||
type ChannelOption func(*model.Channel)
|
||||
|
||||
func WithShared(v bool) ChannelOption {
|
||||
return func(channel *model.Channel) {
|
||||
channel.Shared = model.NewBool(v)
|
||||
}
|
||||
}
|
||||
|
||||
func (th *TestHelper) CreateChannel(team *model.Team, options ...ChannelOption) *model.Channel {
|
||||
return th.createChannel(team, model.CHANNEL_OPEN, options...)
|
||||
}
|
||||
|
||||
func (th *TestHelper) createPrivateChannel(team *model.Team) *model.Channel {
|
||||
return th.createChannel(team, model.CHANNEL_PRIVATE)
|
||||
}
|
||||
|
||||
func (th *TestHelper) createChannel(team *model.Team, channelType string) *model.Channel {
|
||||
func (th *TestHelper) createChannel(team *model.Team, channelType string, options ...ChannelOption) *model.Channel {
|
||||
id := model.NewId()
|
||||
|
||||
channel := &model.Channel{
|
||||
@@ -245,11 +253,32 @@ func (th *TestHelper) createChannel(team *model.Team, channelType string) *model
|
||||
CreatorId: th.BasicUser.Id,
|
||||
}
|
||||
|
||||
for _, option := range options {
|
||||
option(channel)
|
||||
}
|
||||
|
||||
utils.DisableDebugLogForTest()
|
||||
var err *model.AppError
|
||||
if channel, err = th.App.CreateChannel(channel, true); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if channel.IsShared() {
|
||||
id := model.NewId()
|
||||
_, err := th.App.SaveSharedChannel(&model.SharedChannel{
|
||||
ChannelId: channel.Id,
|
||||
TeamId: channel.TeamId,
|
||||
Home: false,
|
||||
ReadOnly: false,
|
||||
ShareName: "shared-" + id,
|
||||
ShareDisplayName: "shared-" + id,
|
||||
CreatorId: th.BasicUser.Id,
|
||||
RemoteId: model.NewId(),
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
utils.EnableDebugLogForTest()
|
||||
return channel
|
||||
}
|
||||
|
||||
88
app/slashcommands/util.go
Обычный файл
88
app/slashcommands/util.go
Обычный файл
@@ -0,0 +1,88 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package slashcommands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v5/model"
|
||||
)
|
||||
|
||||
const (
|
||||
ActionKey = "-action"
|
||||
)
|
||||
|
||||
// responsef creates an ephemeral command response using printf syntax.
|
||||
func responsef(format string, args ...interface{}) *model.CommandResponse {
|
||||
return &model.CommandResponse{
|
||||
ResponseType: model.COMMAND_RESPONSE_TYPE_EPHEMERAL,
|
||||
Text: fmt.Sprintf(format, args...),
|
||||
Type: model.POST_DEFAULT,
|
||||
}
|
||||
}
|
||||
|
||||
// parseNamedArgs parses a command string into a map of arguments. It is assumed the
|
||||
// command string is of the form `<action> --arg1 value1 ...` Supports empty values.
|
||||
// Arg names are limited to [0-9a-zA-Z_].
|
||||
func parseNamedArgs(cmd string) map[string]string {
|
||||
m := make(map[string]string)
|
||||
|
||||
split := strings.Fields(cmd)
|
||||
|
||||
// check for optional action
|
||||
if len(split) >= 2 && !strings.HasPrefix(split[1], "--") {
|
||||
m[ActionKey] = split[1] // prefix with hyphen to avoid collision with arg named "action"
|
||||
}
|
||||
|
||||
for i := 0; i < len(split); i++ {
|
||||
if !strings.HasPrefix(split[i], "--") {
|
||||
continue
|
||||
}
|
||||
var val string
|
||||
arg := trimSpaceAndQuotes(strings.Trim(split[i], "-"))
|
||||
if i < len(split)-1 && !strings.HasPrefix(split[i+1], "--") {
|
||||
val = trimSpaceAndQuotes(split[i+1])
|
||||
}
|
||||
if arg != "" {
|
||||
m[arg] = val
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func trimSpaceAndQuotes(s string) string {
|
||||
trimmed := strings.TrimSpace(s)
|
||||
trimmed = strings.TrimPrefix(trimmed, "\"")
|
||||
trimmed = strings.TrimPrefix(trimmed, "'")
|
||||
trimmed = strings.TrimSuffix(trimmed, "\"")
|
||||
trimmed = strings.TrimSuffix(trimmed, "'")
|
||||
return trimmed
|
||||
}
|
||||
|
||||
func parseBool(s string) (bool, error) {
|
||||
switch strings.ToLower(s) {
|
||||
case "1", "t", "true", "yes", "y":
|
||||
return true, nil
|
||||
case "0", "f", "false", "no", "n":
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("cannot parse '%s' as a boolean", s)
|
||||
}
|
||||
|
||||
func formatTimestamp(ts time.Time) string {
|
||||
if !isToday(ts) {
|
||||
return ts.Format("Jan 2 15:04:05 MST 2006")
|
||||
}
|
||||
date := ts.Format("15:04:05 MST 2006")
|
||||
return fmt.Sprintf("today %s", date)
|
||||
}
|
||||
|
||||
func isToday(ts time.Time) bool {
|
||||
now := time.Now()
|
||||
year, month, day := ts.Date()
|
||||
nowYear, nowMonth, nowDay := now.Date()
|
||||
return year == nowYear && month == nowMonth && day == nowDay
|
||||
}
|
||||
40
app/slashcommands/util_test.go
Обычный файл
40
app/slashcommands/util_test.go
Обычный файл
@@ -0,0 +1,40 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package slashcommands
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestParseNamedArgs(t *testing.T) {
|
||||
data := []struct {
|
||||
name string
|
||||
s string
|
||||
m map[string]string
|
||||
}{
|
||||
{"empty", "", map[string]string{}},
|
||||
{"gibberish", "ifu3ue-h29f8", map[string]string{}},
|
||||
{"action only", "remote status", map[string]string{ActionKey: "status"}},
|
||||
{"no action", "remote --arg1 val1 --arg2 val2", map[string]string{"arg1": "val1", "arg2": "val2"}},
|
||||
{"command only", "remote", map[string]string{}},
|
||||
{"trailing empty arg", "remote add --arg1 val1 --arg2", map[string]string{ActionKey: "add", "arg1": "val1", "arg2": ""}},
|
||||
{"leading empty arg", "remote add --arg1 --arg2 val2", map[string]string{ActionKey: "add", "arg1": "", "arg2": "val2"}},
|
||||
{"weird", "-- -- -- --", map[string]string{}},
|
||||
{"hyphen before action", "remote -- add", map[string]string{}},
|
||||
{"trailing hyphen", "remote add -- ", map[string]string{ActionKey: "add"}},
|
||||
{"hyphen in val", "remote add --arg1 val-1 ", map[string]string{ActionKey: "add", "arg1": "val-1"}},
|
||||
{"quote prefix and suffix", "remote add --arg1 \"val-1\"", map[string]string{ActionKey: "add", "arg1": "val-1"}},
|
||||
{"quote embedded", "remote add --arg1 O'Brien", map[string]string{ActionKey: "add", "arg1": "O'Brien"}},
|
||||
{"quote prefix, suffix, and embedded", "remote add --arg1 \"O'Brien\"", map[string]string{ActionKey: "add", "arg1": "O'Brien"}},
|
||||
{"empty quotes", "remote add --arg1 \"\"", map[string]string{ActionKey: "add", "arg1": ""}},
|
||||
}
|
||||
|
||||
for _, tt := range data {
|
||||
m := parseNamedArgs(tt.s)
|
||||
assert.NotNil(t, m)
|
||||
assert.Equal(t, tt.m, m, tt.name)
|
||||
}
|
||||
}
|
||||
Ссылка в новой задаче
Block a user