snapshot of https://github.com/mattermost/mattermost.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-16 00:29:55 +05:00
commit 240db4e2a7
15309 changed files with 2173406 additions and 0 deletions

View File

@@ -0,0 +1,79 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package slashcommands
import (
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/app"
"github.com/mattermost/mattermost/server/v8/channels/utils"
)
type AutoChannelCreator struct {
a *app.App
userID string
team *model.Team
Fuzzy bool
DisplayNameLen utils.Range
DisplayNameCharset string
NameLen utils.Range
NameCharset string
ChannelType model.ChannelType
CreateTime int64
}
func NewAutoChannelCreator(a *app.App, team *model.Team, userID string) *AutoChannelCreator {
return &AutoChannelCreator{
a: a,
team: team,
userID: userID,
Fuzzy: false,
DisplayNameLen: ChannelDisplayNameLen,
DisplayNameCharset: utils.ALPHANUMERIC,
NameLen: ChannelNameLen,
NameCharset: utils.LOWERCASE,
ChannelType: ChannelType,
CreateTime: 0,
}
}
func (cfg *AutoChannelCreator) createRandomChannel(rctx request.CTX) (*model.Channel, error) {
var displayName string
if cfg.Fuzzy {
displayName = utils.FuzzName()
} else {
displayName = utils.RandomName(cfg.NameLen, cfg.NameCharset)
}
name := utils.RandomName(cfg.NameLen, cfg.NameCharset)
channel := &model.Channel{
TeamId: cfg.team.Id,
DisplayName: displayName,
Name: name,
Type: cfg.ChannelType,
CreatorId: cfg.userID,
CreateAt: cfg.CreateTime,
}
channel, err := cfg.a.CreateChannel(rctx, channel, true)
if err != nil {
return nil, err
}
return channel, nil
}
func (cfg *AutoChannelCreator) CreateTestChannels(rctx request.CTX, num utils.Range) ([]*model.Channel, error) {
numChannels := utils.RandIntFromRange(num)
channels := make([]*model.Channel, numChannels)
for i := range numChannels {
var err error
channels[i], err = cfg.createRandomChannel(rctx)
if err != nil {
return nil, err
}
}
return channels, nil
}

View File

@@ -0,0 +1,32 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package slashcommands
import (
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/channels/utils"
)
const (
UserPassword = "Usr@MMTest12345"
ChannelType = model.ChannelTypeOpen
BTestTeamDisplayName = "TestTeam"
BTestTeamName = "z-z-testdomaina"
BTestTeamEmail = "test@nowhere.com"
BTestTeamType = model.TeamOpen
BTestUserName = "Mr. Testing Tester"
BTestUserEmail = "success+ttester@simulator.amazonses.com"
BTestUserPassword = "Passwd+Us3r1234"
)
var (
TeamNameLen = utils.Range{Begin: 10, End: 20}
TeamDomainNameLen = utils.Range{Begin: 10, End: 20}
TeamEmailLen = utils.Range{Begin: 15, End: 30}
UserNameLen = utils.Range{Begin: 5, End: 20}
UserEmailLen = utils.Range{Begin: 15, End: 30}
ChannelDisplayNameLen = utils.Range{Begin: 10, End: 20}
ChannelNameLen = utils.Range{Begin: 5, End: 20}
TestImageFileNames = []string{"test.png", "testjpg.jpg", "testgif.gif"}
)

View File

@@ -0,0 +1,118 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package slashcommands
import (
"context"
"math/rand"
"time"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/app"
"github.com/mattermost/mattermost/server/v8/channels/utils"
)
type TestEnvironment struct {
Teams []*model.Team
Environments []TeamEnvironment
}
func CreateTestEnvironmentWithTeams(a *app.App, rctx request.CTX, client *model.Client4, rangeTeams utils.Range, rangeChannels utils.Range, rangeUsers utils.Range, rangePosts utils.Range, fuzzy bool) (TestEnvironment, error) {
rand.Seed(time.Now().UTC().UnixNano())
teamCreator := NewAutoTeamCreator(client)
teamCreator.Fuzzy = fuzzy
teams, err := teamCreator.CreateTestTeams(rangeTeams)
if err != nil {
return TestEnvironment{}, err
}
environment := TestEnvironment{teams, make([]TeamEnvironment, len(teams))}
for i, team := range teams {
userCreator := NewAutoUserCreator(a, client, team)
userCreator.Fuzzy = fuzzy
randomUser, err := userCreator.createRandomUser(rctx)
if err != nil {
return TestEnvironment{}, err
}
_, _, err = client.LoginById(context.Background(), randomUser.Id, UserPassword)
if err != nil {
return TestEnvironment{}, err
}
teamEnvironment, err := CreateTestEnvironmentInTeam(a, rctx, client, team, rangeChannels, rangeUsers, rangePosts, fuzzy)
if err != nil {
return TestEnvironment{}, err
}
environment.Environments[i] = teamEnvironment
}
return environment, nil
}
func CreateTestEnvironmentInTeam(a *app.App, rctx request.CTX, client *model.Client4, team *model.Team, rangeChannels utils.Range, rangeUsers utils.Range, rangePosts utils.Range, fuzzy bool) (TeamEnvironment, error) {
rand.Seed(time.Now().UTC().UnixNano())
// We need to create at least one user
if rangeUsers.Begin <= 0 {
rangeUsers.Begin = 1
}
userCreator := NewAutoUserCreator(a, client, team)
userCreator.Fuzzy = fuzzy
users, err := userCreator.CreateTestUsers(rctx, rangeUsers)
if err != nil {
return TeamEnvironment{}, err
}
usernames := make([]string, len(users))
for i, user := range users {
usernames[i] = user.Username
}
channelCreator := NewAutoChannelCreator(a, team, users[0].Id)
channelCreator.Fuzzy = fuzzy
channels, err := channelCreator.CreateTestChannels(rctx, rangeChannels)
if err != nil {
return TeamEnvironment{}, err
}
// Have every user join every channel
for _, user := range users {
for _, channel := range channels {
_, _, err := client.LoginById(context.Background(), user.Id, UserPassword)
if err != nil {
return TeamEnvironment{}, err
}
_, _, err = client.AddChannelMember(context.Background(), channel.Id, user.Id)
if err != nil {
return TeamEnvironment{}, err
}
}
}
numPosts := utils.RandIntFromRange(rangePosts)
numImages := utils.RandIntFromRange(rangePosts) / 4
for range numPosts {
user := users[utils.RandIntFromRange(utils.Range{Begin: 0, End: len(users) - 1})]
_, _, err := client.LoginById(context.Background(), user.Id, UserPassword)
if err != nil {
return TeamEnvironment{}, err
}
for i, channel := range channels {
postCreator := NewAutoPostCreator(a, channel.Id, user.Id)
postCreator.HasImage = i < numImages
postCreator.Users = usernames
postCreator.Fuzzy = fuzzy
_, err := postCreator.CreateRandomPost(rctx)
if err != nil {
return TeamEnvironment{}, err
}
}
}
return TeamEnvironment{users, channels}, nil
}

View File

@@ -0,0 +1,125 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package slashcommands
import (
"bytes"
"io"
"os"
"path/filepath"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/app"
"github.com/mattermost/mattermost/server/v8/channels/utils"
"github.com/mattermost/mattermost/server/v8/channels/utils/fileutils"
)
type AutoPostCreator struct {
a *app.App
channelid string
userid string
Fuzzy bool
TextLength utils.Range
HasImage bool
ImageFilenames []string
Users []string
UsersToPostFrom []string
Mentions utils.Range
Tags utils.Range
CreateTime int64
postsCreated int
}
// Automatic poster used for testing
func NewAutoPostCreator(a *app.App, channelid, userid string) *AutoPostCreator {
return &AutoPostCreator{
a: a,
channelid: channelid,
userid: userid,
Fuzzy: false,
TextLength: utils.Range{Begin: 100, End: 200},
HasImage: false,
ImageFilenames: TestImageFileNames,
Users: []string{},
UsersToPostFrom: []string{},
Mentions: utils.Range{Begin: 0, End: 5},
Tags: utils.Range{Begin: 0, End: 7},
CreateTime: 0,
postsCreated: 0,
}
}
func (cfg *AutoPostCreator) UploadTestFile(rctx request.CTX) ([]string, error) {
filename := cfg.ImageFilenames[utils.RandIntFromRange(utils.Range{Begin: 0, End: len(cfg.ImageFilenames) - 1})]
path, _ := fileutils.FindDir("tests")
file, err := os.Open(filepath.Join(path, filename))
if err != nil {
return nil, err
}
defer file.Close()
data := &bytes.Buffer{}
_, err = io.Copy(data, file)
if err != nil {
return nil, err
}
fileResp, err2 := cfg.a.UploadFile(rctx, data.Bytes(), cfg.channelid, filename)
if err2 != nil {
return nil, err2
}
return []string{fileResp.Id}, nil
}
func (cfg *AutoPostCreator) CreateRandomPost(rctx request.CTX) (*model.Post, error) {
return cfg.CreateRandomPostNested(rctx, "")
}
func (cfg *AutoPostCreator) CreateRandomPostNested(rctx request.CTX, rootID string) (*model.Post, error) {
var fileIDs []string
if cfg.HasImage {
var err error
fileIDs, err = cfg.UploadTestFile(rctx)
if err != nil {
return nil, err
}
}
var postText string
if cfg.Fuzzy {
postText = utils.FuzzPost()
} else {
postText = utils.RandomText(cfg.TextLength, cfg.Tags, cfg.Mentions, cfg.Users)
}
post := &model.Post{
ChannelId: cfg.channelid,
UserId: cfg.userid,
RootId: rootID,
Message: postText,
FileIds: fileIDs,
}
if cfg.CreateTime != 0 {
// Creating posts with the exact same timestamp results in some posts being skipped
// when they are retrieved by the API based on timestamp.
post.CreateAt = cfg.CreateTime + int64(cfg.postsCreated)
}
if len(cfg.UsersToPostFrom) != 0 {
i := utils.RandIntFromRange(utils.Range{Begin: 0, End: len(cfg.UsersToPostFrom)})
if i < len(cfg.UsersToPostFrom) {
post.UserId = cfg.UsersToPostFrom[i]
}
}
rpost, _, err := cfg.a.CreatePostMissingChannel(rctx, post, true, true)
if err != nil {
return nil, err
}
cfg.postsCreated += 1
return rpost, nil
}

View File

@@ -0,0 +1,82 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package slashcommands
import (
"context"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/channels/utils"
)
type TeamEnvironment struct {
Users []*model.User
Channels []*model.Channel
}
type AutoTeamCreator struct {
client *model.Client4
Fuzzy bool
NameLength utils.Range
NameCharset string
DomainLength utils.Range
DomainCharset string
EmailLength utils.Range
EmailCharset string
}
func NewAutoTeamCreator(client *model.Client4) *AutoTeamCreator {
return &AutoTeamCreator{
client: client,
Fuzzy: false,
NameLength: TeamNameLen,
NameCharset: utils.LOWERCASE,
DomainLength: TeamDomainNameLen,
DomainCharset: utils.LOWERCASE,
EmailLength: TeamEmailLen,
EmailCharset: utils.LOWERCASE,
}
}
func (cfg *AutoTeamCreator) createRandomTeam() (*model.Team, error) {
var teamEmail string
var teamDisplayName string
var teamName string
teamEmail = "success+" + model.NewId() + "simulator.amazonses.com"
if cfg.Fuzzy {
teamDisplayName = utils.FuzzName()
teamName = model.NewRandomTeamName()
} else {
teamDisplayName = utils.RandomName(cfg.NameLength, cfg.NameCharset)
teamName = utils.RandomName(cfg.NameLength, cfg.NameCharset) + model.NewId()
}
team := &model.Team{
DisplayName: teamDisplayName,
Name: teamName,
Email: teamEmail,
Type: model.TeamOpen,
}
createdTeam, _, err := cfg.client.CreateTeam(context.Background(), team)
if err != nil {
return nil, err
}
return createdTeam, nil
}
func (cfg *AutoTeamCreator) CreateTestTeams(num utils.Range) ([]*model.Team, error) {
numTeams := utils.RandIntFromRange(num)
teams := make([]*model.Team, numTeams)
for i := range numTeams {
var err error
teams[i], err = cfg.createRandomTeam()
if err != nil {
return nil, err
}
}
return teams, nil
}

View File

@@ -0,0 +1,152 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package slashcommands
import (
"context"
"errors"
"net/http"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/app"
"github.com/mattermost/mattermost/server/v8/channels/store"
"github.com/mattermost/mattermost/server/v8/channels/utils"
)
type AutoUserCreator struct {
app *app.App
client *model.Client4
team *model.Team
EmailLength utils.Range
EmailCharset string
NameLength utils.Range
NameCharset string
Fuzzy bool
JoinTime int64
}
func NewAutoUserCreator(a *app.App, client *model.Client4, team *model.Team) *AutoUserCreator {
return &AutoUserCreator{
app: a,
client: client,
team: team,
EmailLength: UserEmailLen,
EmailCharset: utils.LOWERCASE,
NameLength: UserNameLen,
NameCharset: utils.LOWERCASE,
Fuzzy: false,
JoinTime: 0,
}
}
// Basic test team and user so you always know one
func CreateBasicUser(rctx request.CTX, a *app.App, client *model.Client4) error {
found, _, _ := client.TeamExists(context.Background(), BTestTeamName, "")
if found {
return nil
}
newteam := &model.Team{DisplayName: BTestTeamDisplayName, Name: BTestTeamName, Email: BTestTeamEmail, Type: BTestTeamType}
basicteam, _, err := client.CreateTeam(context.Background(), newteam)
if err != nil {
return err
}
newuser := &model.User{Email: BTestUserEmail, Nickname: BTestUserName, Password: BTestUserPassword}
ruser, _, err := client.CreateUser(context.Background(), newuser)
if err != nil {
return err
}
_, err = a.Srv().Store().User().VerifyEmail(ruser.Id, ruser.Email)
if err != nil {
return model.NewAppError("CreateBasicUser", "app.user.verify_email.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
if _, nErr := a.Srv().Store().Team().SaveMember(rctx, &model.TeamMember{TeamId: basicteam.Id, UserId: ruser.Id, CreateAt: model.GetMillis()}, *a.Config().TeamSettings.MaxUsersPerTeam); nErr != nil {
var appErr *model.AppError
var conflictErr *store.ErrConflict
var limitExceededErr *store.ErrLimitExceeded
switch {
case errors.As(nErr, &appErr): // in case we haven't converted to plain error.
return appErr
case errors.As(nErr, &conflictErr):
return model.NewAppError("CreateBasicUser", "app.create_basic_user.save_member.conflict.app_error", nil, "", http.StatusBadRequest).Wrap(nErr)
case errors.As(nErr, &limitExceededErr):
return model.NewAppError("CreateBasicUser", "app.create_basic_user.save_member.max_accounts.app_error", nil, "", http.StatusBadRequest).Wrap(nErr)
default: // last fallback in case it doesn't map to an existing app error.
return model.NewAppError("CreateBasicUser", "app.create_basic_user.save_member.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr)
}
}
return nil
}
func (cfg *AutoUserCreator) createRandomUser(rctx request.CTX) (*model.User, error) {
var userEmail string
var userName string
if cfg.Fuzzy {
userEmail = "success+" + model.NewId() + "@simulator.amazonses.com"
userName = "a" + utils.FuzzName()
} else {
userEmail = "success+" + model.NewId() + "@simulator.amazonses.com"
userName = "a" + utils.RandomName(cfg.NameLength, cfg.NameCharset)
}
user := &model.User{
Email: userEmail,
Nickname: userName,
Password: UserPassword,
CreateAt: cfg.JoinTime,
}
ruser, appErr := cfg.app.CreateUserWithInviteId(rctx, user, cfg.team.InviteId, "")
if appErr != nil {
return nil, appErr
}
status := &model.Status{
UserId: ruser.Id,
Status: model.StatusOnline,
Manual: false,
LastActivityAt: ruser.CreateAt,
ActiveChannel: "",
}
if err := cfg.app.Srv().Store().Status().SaveOrUpdate(status); err != nil {
return nil, err
}
// We need to cheat to verify the user's email
_, err := cfg.app.Srv().Store().User().VerifyEmail(ruser.Id, ruser.Email)
if err != nil {
return nil, err
}
if cfg.JoinTime != 0 {
teamMember, appErr := cfg.app.GetTeamMember(rctx, cfg.team.Id, ruser.Id)
if appErr != nil {
return nil, appErr
}
teamMember.CreateAt = cfg.JoinTime
_, err := cfg.app.Srv().Store().Team().UpdateMember(rctx, teamMember)
if err != nil {
return nil, err
}
}
return ruser, nil
}
func (cfg *AutoUserCreator) CreateTestUsers(rctx request.CTX, num utils.Range) ([]*model.User, error) {
numUsers := utils.RandIntFromRange(num)
users := make([]*model.User, numUsers)
for i := range numUsers {
var err error
users[i], err = cfg.createRandomUser(rctx)
if err != nil {
return nil, err
}
}
return users, nil
}

View File

@@ -0,0 +1,41 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package slashcommands
import (
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/i18n"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/app"
)
type AwayProvider struct {
}
const (
CmdAway = "away"
)
func init() {
app.RegisterCommandProvider(&AwayProvider{})
}
func (*AwayProvider) GetTrigger() string {
return CmdAway
}
func (*AwayProvider) GetCommand(a *app.App, T i18n.TranslateFunc) *model.Command {
return &model.Command{
Trigger: CmdAway,
AutoComplete: true,
AutoCompleteDesc: T("api.command_away.desc"),
DisplayName: T("api.command_away.name"),
}
}
func (*AwayProvider) DoCommand(a *app.App, _ request.CTX, args *model.CommandArgs, message string) *model.CommandResponse {
a.SetStatusAwayIfNeeded(args.UserId, true)
return &model.CommandResponse{ResponseType: model.CommandResponseTypeEphemeral, Text: args.T("api.command_away.success")}
}

View File

@@ -0,0 +1,110 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package slashcommands
import (
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/i18n"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/app"
)
type HeaderProvider struct {
}
const (
CmdHeader = "header"
)
func init() {
app.RegisterCommandProvider(&HeaderProvider{})
}
func (*HeaderProvider) GetTrigger() string {
return CmdHeader
}
func (*HeaderProvider) GetCommand(a *app.App, T i18n.TranslateFunc) *model.Command {
return &model.Command{
Trigger: CmdHeader,
AutoComplete: true,
AutoCompleteDesc: T("api.command_channel_header.desc"),
AutoCompleteHint: T("api.command_channel_header.hint"),
DisplayName: T("api.command_channel_header.name"),
}
}
func (*HeaderProvider) DoCommand(a *app.App, rctx request.CTX, args *model.CommandArgs, message string) *model.CommandResponse {
channel, err := a.GetChannel(rctx, args.ChannelId)
if err != nil {
return &model.CommandResponse{
Text: args.T("api.command_channel_header.channel.app_error"),
ResponseType: model.CommandResponseTypeEphemeral,
}
}
switch channel.Type {
case model.ChannelTypeOpen:
if ok, _ := a.HasPermissionToChannel(rctx, args.UserId, args.ChannelId, model.PermissionManagePublicChannelProperties); !ok {
return &model.CommandResponse{
Text: args.T("api.command_channel_header.permission.app_error"),
ResponseType: model.CommandResponseTypeEphemeral,
}
}
case model.ChannelTypePrivate:
if ok, _ := a.HasPermissionToChannel(rctx, args.UserId, args.ChannelId, model.PermissionManagePrivateChannelProperties); !ok {
return &model.CommandResponse{
Text: args.T("api.command_channel_header.permission.app_error"),
ResponseType: model.CommandResponseTypeEphemeral,
}
}
case model.ChannelTypeGroup, model.ChannelTypeDirect:
// Modifying the header is not linked to any specific permission for group/dm channels, so just check for membership.
var channelMember *model.ChannelMember
channelMember, err = a.GetChannelMember(rctx, args.ChannelId, args.UserId)
if err != nil || channelMember == nil {
return &model.CommandResponse{
Text: args.T("api.command_channel_header.permission.app_error"),
ResponseType: model.CommandResponseTypeEphemeral,
}
}
default:
return &model.CommandResponse{
Text: args.T("api.command_channel_header.permission.app_error"),
ResponseType: model.CommandResponseTypeEphemeral,
}
}
if message == "" {
return &model.CommandResponse{
Text: args.T("api.command_channel_header.message.app_error"),
ResponseType: model.CommandResponseTypeEphemeral,
}
}
patch := &model.ChannelPatch{
Header: new(string),
}
*patch.Header = message
_, err = a.PatchChannel(rctx, channel, patch, args.UserId)
if err != nil {
text := args.T("api.command_channel_header.update_channel.app_error")
if err.Id == "model.channel.is_valid.header.app_error" {
text = args.T("api.command_channel_header.update_channel.max_length", map[string]any{
"MaxLength": model.ChannelHeaderMaxRunes,
})
}
return &model.CommandResponse{
Text: text,
ResponseType: model.CommandResponseTypeEphemeral,
}
}
return &model.CommandResponse{}
}

View File

@@ -0,0 +1,121 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package slashcommands
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/mattermost/mattermost/server/public/model"
)
func TestHeaderProviderDoCommand(t *testing.T) {
th := setup(t).initBasic(t)
hp := HeaderProvider{}
th.addPermissionToRole(t, model.PermissionManagePublicChannelProperties.Id, model.ChannelUserRoleId)
// Try a public channel *with* permission.
args := &model.CommandArgs{
T: func(s string, args ...any) string { return s },
ChannelId: th.BasicChannel.Id,
UserId: th.BasicUser.Id,
}
for msg, expected := range map[string]string{
"": "api.command_channel_header.message.app_error",
"hello": "",
} {
actual := hp.DoCommand(th.App, th.Context, args, msg).Text
assert.Equal(t, expected, actual)
}
th.removePermissionFromRole(t, model.PermissionManagePublicChannelProperties.Id, model.ChannelUserRoleId)
// Try a public channel *without* permission.
args = &model.CommandArgs{
T: func(s string, args ...any) string { return s },
ChannelId: th.BasicChannel.Id,
UserId: th.BasicUser.Id,
}
actual := hp.DoCommand(th.App, th.Context, args, "hello").Text
assert.Equal(t, "api.command_channel_header.permission.app_error", actual)
th.addPermissionToRole(t, model.PermissionManagePrivateChannelProperties.Id, model.ChannelUserRoleId)
// Try a private channel *with* permission.
privateChannel := th.createPrivateChannel(t, th.BasicTeam)
args = &model.CommandArgs{
T: func(s string, args ...any) string { return s },
ChannelId: privateChannel.Id,
UserId: th.BasicUser.Id,
}
actual = hp.DoCommand(th.App, th.Context, args, "hello").Text
assert.Equal(t, "", actual)
th.removePermissionFromRole(t, model.PermissionManagePrivateChannelProperties.Id, model.ChannelUserRoleId)
// Try a private channel *without* permission.
args = &model.CommandArgs{
T: func(s string, args ...any) string { return s },
ChannelId: privateChannel.Id,
UserId: th.BasicUser.Id,
}
actual = hp.DoCommand(th.App, th.Context, args, "hello").Text
assert.Equal(t, "api.command_channel_header.permission.app_error", actual)
// Try a group channel *with* being a member.
user1 := th.createUser(t)
user2 := th.createUser(t)
user3 := th.createUser(t)
groupChannel := th.createGroupChannel(t, user1, user2)
args = &model.CommandArgs{
T: func(s string, args ...any) string { return s },
ChannelId: groupChannel.Id,
UserId: user1.Id,
}
actual = hp.DoCommand(th.App, th.Context, args, "hello").Text
assert.Equal(t, "", actual)
// Try a group channel *without* being a member.
args = &model.CommandArgs{
T: func(s string, args ...any) string { return s },
ChannelId: groupChannel.Id,
UserId: user3.Id,
}
actual = hp.DoCommand(th.App, th.Context, args, "hello").Text
assert.Equal(t, "api.command_channel_header.permission.app_error", actual)
// Try a direct channel *with* being a member.
directChannel := th.createDmChannel(t, user1)
args = &model.CommandArgs{
T: func(s string, args ...any) string { return s },
ChannelId: directChannel.Id,
UserId: th.BasicUser.Id,
}
actual = hp.DoCommand(th.App, th.Context, args, "hello").Text
assert.Equal(t, "", actual)
// Try a direct channel *without* being a member.
args = &model.CommandArgs{
T: func(s string, args ...any) string { return s },
ChannelId: directChannel.Id,
UserId: user2.Id,
}
actual = hp.DoCommand(th.App, th.Context, args, "hello").Text
assert.Equal(t, "api.command_channel_header.permission.app_error", actual)
}

View File

@@ -0,0 +1,97 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package slashcommands
import (
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/i18n"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/app"
)
type PurposeProvider struct {
}
const (
CmdPurpose = "purpose"
)
func init() {
app.RegisterCommandProvider(&PurposeProvider{})
}
func (*PurposeProvider) GetTrigger() string {
return CmdPurpose
}
func (*PurposeProvider) GetCommand(a *app.App, T i18n.TranslateFunc) *model.Command {
return &model.Command{
Trigger: CmdPurpose,
AutoComplete: true,
AutoCompleteDesc: T("api.command_channel_purpose.desc"),
AutoCompleteHint: T("api.command_channel_purpose.hint"),
DisplayName: T("api.command_channel_purpose.name"),
}
}
func (*PurposeProvider) DoCommand(a *app.App, rctx request.CTX, args *model.CommandArgs, message string) *model.CommandResponse {
channel, err := a.GetChannel(rctx, args.ChannelId)
if err != nil {
return &model.CommandResponse{
Text: args.T("api.command_channel_purpose.channel.app_error"),
ResponseType: model.CommandResponseTypeEphemeral,
}
}
switch channel.Type {
case model.ChannelTypeOpen:
if ok, _ := a.HasPermissionToChannel(rctx, args.UserId, args.ChannelId, model.PermissionManagePublicChannelProperties); !ok {
return &model.CommandResponse{
Text: args.T("api.command_channel_purpose.permission.app_error"),
ResponseType: model.CommandResponseTypeEphemeral,
}
}
case model.ChannelTypePrivate:
if ok, _ := a.HasPermissionToChannel(rctx, args.UserId, args.ChannelId, model.PermissionManagePrivateChannelProperties); !ok {
return &model.CommandResponse{
Text: args.T("api.command_channel_purpose.permission.app_error"),
ResponseType: model.CommandResponseTypeEphemeral,
}
}
default:
return &model.CommandResponse{
Text: args.T("api.command_channel_purpose.direct_group.app_error"),
ResponseType: model.CommandResponseTypeEphemeral,
}
}
if message == "" {
return &model.CommandResponse{
Text: args.T("api.command_channel_purpose.message.app_error"),
ResponseType: model.CommandResponseTypeEphemeral,
}
}
patch := &model.ChannelPatch{
Purpose: new(string),
}
*patch.Purpose = message
_, err = a.PatchChannel(rctx, channel, patch, args.UserId)
if err != nil {
text := args.T("api.command_channel_purpose.update_channel.app_error")
if err.Id == "model.channel.is_valid.purpose.app_error" {
text = args.T("api.command_channel_purpose.update_channel.max_length", map[string]any{
"MaxLength": model.ChannelPurposeMaxRunes,
})
}
return &model.CommandResponse{
Text: text,
ResponseType: model.CommandResponseTypeEphemeral,
}
}
return &model.CommandResponse{}
}

View File

@@ -0,0 +1,96 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package slashcommands
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/mattermost/mattermost/server/public/model"
)
func TestPurposeProviderDoCommand(t *testing.T) {
th := setup(t).initBasic(t)
pp := PurposeProvider{}
// Try a public channel *with* permission.
th.addPermissionToRole(t, model.PermissionManagePublicChannelProperties.Id, model.ChannelUserRoleId)
args := &model.CommandArgs{
T: func(s string, args ...any) string { return s },
ChannelId: th.BasicChannel.Id,
UserId: th.BasicUser.Id,
}
for msg, expected := range map[string]string{
"": "api.command_channel_purpose.message.app_error",
"hello": "",
} {
actual := pp.DoCommand(th.App, th.Context, args, msg).Text
assert.Equal(t, expected, actual)
}
// Try a public channel *without* permission.
th.removePermissionFromRole(t, model.PermissionManagePublicChannelProperties.Id, model.ChannelUserRoleId)
args = &model.CommandArgs{
T: func(s string, args ...any) string { return s },
ChannelId: th.BasicChannel.Id,
}
actual := pp.DoCommand(th.App, th.Context, args, "hello").Text
assert.Equal(t, "api.command_channel_purpose.permission.app_error", actual)
// Try a private channel *with* permission.
privateChannel := th.createPrivateChannel(t, th.BasicTeam)
th.addPermissionToRole(t, model.PermissionManagePrivateChannelProperties.Id, model.ChannelUserRoleId)
args = &model.CommandArgs{
T: func(s string, args ...any) string { return s },
ChannelId: privateChannel.Id,
UserId: th.BasicUser.Id,
}
actual = pp.DoCommand(th.App, th.Context, args, "hello").Text
assert.Equal(t, "", actual)
// Try a private channel *without* permission.
th.removePermissionFromRole(t, model.PermissionManagePrivateChannelProperties.Id, model.ChannelUserRoleId)
args = &model.CommandArgs{
T: func(s string, args ...any) string { return s },
ChannelId: privateChannel.Id,
}
actual = pp.DoCommand(th.App, th.Context, args, "hello").Text
assert.Equal(t, "api.command_channel_purpose.permission.app_error", actual)
// Try a group channel *with* being a member.
user1 := th.createUser(t)
user2 := th.createUser(t)
groupChannel := th.createGroupChannel(t, user1, user2)
args = &model.CommandArgs{
T: func(s string, args ...any) string { return s },
ChannelId: groupChannel.Id,
}
actual = pp.DoCommand(th.App, th.Context, args, "hello").Text
assert.Equal(t, "api.command_channel_purpose.direct_group.app_error", actual)
// Try a direct channel *with* being a member.
directChannel := th.createDmChannel(t, user1)
args = &model.CommandArgs{
T: func(s string, args ...any) string { return s },
ChannelId: directChannel.Id,
}
actual = pp.DoCommand(th.App, th.Context, args, "hello").Text
assert.Equal(t, "api.command_channel_purpose.direct_group.app_error", actual)
}

View File

@@ -0,0 +1,104 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package slashcommands
import (
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/i18n"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/app"
)
type RenameProvider struct {
}
const (
CmdRename = "rename"
)
func init() {
app.RegisterCommandProvider(&RenameProvider{})
}
func (*RenameProvider) GetTrigger() string {
return CmdRename
}
func (*RenameProvider) GetCommand(a *app.App, T i18n.TranslateFunc) *model.Command {
renameAutocompleteData := model.NewAutocompleteData(CmdRename, T("api.command_channel_rename.hint"), T("api.command_channel_rename.desc"))
renameAutocompleteData.AddTextArgument(T("api.command_channel_rename.hint"), "[text]", "")
return &model.Command{
Trigger: CmdRename,
AutoComplete: true,
AutoCompleteDesc: T("api.command_channel_rename.desc"),
AutoCompleteHint: T("api.command_channel_rename.hint"),
DisplayName: T("api.command_channel_rename.name"),
AutocompleteData: renameAutocompleteData,
}
}
func (*RenameProvider) DoCommand(a *app.App, rctx request.CTX, args *model.CommandArgs, message string) *model.CommandResponse {
channel, err := a.GetChannel(rctx, args.ChannelId)
if err != nil {
return &model.CommandResponse{
Text: args.T("api.command_channel_rename.channel.app_error"),
ResponseType: model.CommandResponseTypeEphemeral,
}
}
switch channel.Type {
case model.ChannelTypeOpen:
if ok, _ := a.HasPermissionToChannel(rctx, args.UserId, args.ChannelId, model.PermissionManagePublicChannelProperties); !ok {
return &model.CommandResponse{
Text: args.T("api.command_channel_rename.permission.app_error"),
ResponseType: model.CommandResponseTypeEphemeral,
}
}
case model.ChannelTypePrivate:
if ok, _ := a.HasPermissionToChannel(rctx, args.UserId, args.ChannelId, model.PermissionManagePrivateChannelProperties); !ok {
return &model.CommandResponse{
Text: args.T("api.command_channel_rename.permission.app_error"),
ResponseType: model.CommandResponseTypeEphemeral,
}
}
default:
return &model.CommandResponse{Text: args.T("api.command_channel_rename.direct_group.app_error"), ResponseType: model.CommandResponseTypeEphemeral}
}
if message == "" {
return &model.CommandResponse{
Text: args.T("api.command_channel_rename.message.app_error"),
ResponseType: model.CommandResponseTypeEphemeral,
}
} else if len(message) > model.ChannelNameMaxLength {
return &model.CommandResponse{
Text: args.T("api.command_channel_rename.too_long.app_error", map[string]any{
"Length": model.ChannelNameMaxLength,
}),
ResponseType: model.CommandResponseTypeEphemeral,
}
} else if len(message) < model.ChannelNameMinLength {
return &model.CommandResponse{
Text: args.T("api.command_channel_rename.too_short.app_error", map[string]any{
"Length": model.ChannelNameMinLength,
}),
ResponseType: model.CommandResponseTypeEphemeral,
}
}
patch := &model.ChannelPatch{
DisplayName: new(string),
}
*patch.DisplayName = message
_, err = a.PatchChannel(rctx, channel, patch, args.UserId)
if err != nil {
return &model.CommandResponse{
Text: args.T("api.command_channel_rename.update_channel.app_error"),
ResponseType: model.CommandResponseTypeEphemeral,
}
}
return &model.CommandResponse{}
}

View File

@@ -0,0 +1,103 @@
// 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/public/model"
)
func TestRenameProviderDoCommand(t *testing.T) {
th := setup(t).initBasic(t)
th.addPermissionToRole(t, model.PermissionManagePublicChannelProperties.Id, model.ChannelUserRoleId)
rp := RenameProvider{}
args := &model.CommandArgs{
T: func(s string, args ...any) string { return s },
ChannelId: th.BasicChannel.Id,
UserId: th.BasicUser.Id,
}
// Table Test for basic cases. Blank text in response indicates success
for msg, expected := range map[string]string{
"": "api.command_channel_rename.message.app_error",
"o": "",
"joram": "",
"More than 22 chars but less than 64": "",
strings.Repeat("12345", 13): "api.command_channel_rename.too_long.app_error",
} {
actual := rp.DoCommand(th.App, th.Context, args, msg).Text
assert.Equal(t, expected, actual)
}
// Try a public channel *without* permission.
th.removePermissionFromRole(t, model.PermissionManagePublicChannelProperties.Id, model.ChannelUserRoleId)
args = &model.CommandArgs{
T: func(s string, args ...any) string { return s },
ChannelId: th.BasicChannel.Id,
UserId: th.BasicUser.Id,
}
actual := rp.DoCommand(th.App, th.Context, args, "hello").Text
assert.Equal(t, "api.command_channel_rename.permission.app_error", actual)
// Try a private channel *with* permission.
privateChannel := th.createPrivateChannel(t, th.BasicTeam)
th.addPermissionToRole(t, model.PermissionManagePrivateChannelProperties.Id, model.ChannelUserRoleId)
args = &model.CommandArgs{
T: func(s string, args ...any) string { return s },
ChannelId: privateChannel.Id,
UserId: th.BasicUser.Id,
}
actual = rp.DoCommand(th.App, th.Context, args, "hello").Text
assert.Equal(t, "", actual)
// Try a private channel *without* permission.
th.removePermissionFromRole(t, model.PermissionManagePrivateChannelProperties.Id, model.ChannelUserRoleId)
args = &model.CommandArgs{
T: func(s string, args ...any) string { return s },
ChannelId: privateChannel.Id,
UserId: th.BasicUser.Id,
}
actual = rp.DoCommand(th.App, th.Context, args, "hello").Text
assert.Equal(t, "api.command_channel_rename.permission.app_error", actual)
// Try a group channel *with* being a member.
user1 := th.createUser(t)
user2 := th.createUser(t)
groupChannel := th.createGroupChannel(t, user1, user2)
args = &model.CommandArgs{
T: func(s string, args ...any) string { return s },
ChannelId: groupChannel.Id,
UserId: th.BasicUser.Id,
}
actual = rp.DoCommand(th.App, th.Context, args, "hello").Text
assert.Equal(t, "api.command_channel_rename.direct_group.app_error", actual)
// Try a direct channel *with* being a member.
directChannel := th.createDmChannel(t, user1)
args = &model.CommandArgs{
T: func(s string, args ...any) string { return s },
ChannelId: directChannel.Id,
UserId: th.BasicUser.Id,
}
actual = rp.DoCommand(th.App, th.Context, args, "hello").Text
assert.Equal(t, "api.command_channel_rename.direct_group.app_error", actual)
}

View File

@@ -0,0 +1,46 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package slashcommands
import (
"strings"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/i18n"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/app"
)
type CodeProvider struct {
}
const (
CmdCode = "code"
)
func init() {
app.RegisterCommandProvider(&CodeProvider{})
}
func (*CodeProvider) GetTrigger() string {
return CmdCode
}
func (*CodeProvider) GetCommand(a *app.App, T i18n.TranslateFunc) *model.Command {
return &model.Command{
Trigger: CmdCode,
AutoComplete: true,
AutoCompleteDesc: T("api.command_code.desc"),
AutoCompleteHint: T("api.command_code.hint"),
DisplayName: T("api.command_code.name"),
}
}
func (*CodeProvider) DoCommand(a *app.App, rctx request.CTX, args *model.CommandArgs, message string) *model.CommandResponse {
if message == "" {
return &model.CommandResponse{Text: args.T("api.command_code.message.app_error"), ResponseType: model.CommandResponseTypeEphemeral}
}
rmsg := " " + strings.Join(strings.Split(message, "\n"), "\n ")
return &model.CommandResponse{ResponseType: model.CommandResponseTypeInChannel, Text: rmsg, SkipSlackParsing: true}
}

View File

@@ -0,0 +1,29 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package slashcommands
import (
"testing"
"github.com/mattermost/mattermost/server/public/model"
)
func TestCodeProviderDoCommand(t *testing.T) {
cp := CodeProvider{}
args := &model.CommandArgs{
T: func(s string, args ...any) string { return s },
}
for msg, expected := range map[string]string{
"": "api.command_code.message.app_error",
"foo": " foo",
"foo\nbar": " foo\n bar",
"foo\nbar\n": " foo\n bar\n ",
} {
actual := cp.DoCommand(nil, nil, args, msg).Text
if actual != expected {
t.Errorf("expected `%v`, got `%v`", expected, actual)
}
}
}

View File

@@ -0,0 +1,139 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package slashcommands
import (
"regexp"
"strings"
"unicode/utf8"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/i18n"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/app"
)
type CustomStatusProvider struct {
}
const (
CmdCustomStatus = app.CmdCustomStatusTrigger
CmdCustomStatusClear = "clear"
)
func init() {
app.RegisterCommandProvider(&CustomStatusProvider{})
}
func (*CustomStatusProvider) GetTrigger() string {
return CmdCustomStatus
}
func (*CustomStatusProvider) GetCommand(a *app.App, T i18n.TranslateFunc) *model.Command {
return &model.Command{
Trigger: CmdCustomStatus,
AutoComplete: true,
AutoCompleteDesc: T("api.command_custom_status.desc"),
AutoCompleteHint: T("api.command_custom_status.hint"),
DisplayName: T("api.command_custom_status.name"),
}
}
func (*CustomStatusProvider) DoCommand(a *app.App, rctx request.CTX, args *model.CommandArgs, message string) *model.CommandResponse {
if !*a.Config().TeamSettings.EnableCustomUserStatuses {
return nil
}
message = strings.TrimSpace(message)
if message == CmdCustomStatusClear {
if err := a.RemoveCustomStatus(rctx, args.UserId); err != nil {
rctx.Logger().Debug(err.Error())
return &model.CommandResponse{Text: args.T("api.command_custom_status.clear.app_error"), ResponseType: model.CommandResponseTypeEphemeral}
}
return &model.CommandResponse{
ResponseType: model.CommandResponseTypeEphemeral,
Text: args.T("api.command_custom_status.clear.success"),
}
}
customStatus := GetCustomStatus(message)
customStatus.PreSave()
if err := a.SetCustomStatus(rctx, args.UserId, customStatus); err != nil {
rctx.Logger().Debug(err.Error())
return &model.CommandResponse{Text: args.T("api.command_custom_status.app_error"), ResponseType: model.CommandResponseTypeEphemeral}
}
return &model.CommandResponse{
ResponseType: model.CommandResponseTypeEphemeral,
Text: args.T("api.command_custom_status.success", map[string]any{
"EmojiName": ":" + customStatus.Emoji + ":",
"StatusMessage": customStatus.Text,
}),
}
}
func GetCustomStatus(message string) *model.CustomStatus {
customStatus := &model.CustomStatus{
Emoji: model.DefaultCustomStatusEmoji,
Text: message,
}
firstEmojiLocations := model.EmojiPattern.FindIndex([]byte(message))
if len(firstEmojiLocations) > 0 && firstEmojiLocations[0] == 0 {
// emoji found at starting index
customStatus.Emoji = message[firstEmojiLocations[0]+1 : firstEmojiLocations[1]-1]
customStatus.Text = strings.TrimSpace(message[firstEmojiLocations[1]:])
return customStatus
}
if message == "" {
return customStatus
}
spaceSeparatedMessage := strings.Fields(message)
if len(spaceSeparatedMessage) == 0 {
return customStatus
}
emojiString := spaceSeparatedMessage[0]
var unicode []string
for utf8.RuneCountInString(emojiString) >= 1 {
codepoint, size := utf8.DecodeRuneInString(emojiString)
code := model.RuneToHexadecimalString(codepoint)
unicode = append(unicode, code)
emojiString = emojiString[size:]
}
unicodeString := removeUnicodeSkinTone(strings.Join(unicode, "-"))
emoji, count := model.GetEmojiNameFromUnicode(unicodeString)
if count > 0 {
customStatus.Emoji = emoji
textString := strings.Join(spaceSeparatedMessage[1:], " ")
customStatus.Text = strings.TrimSpace(textString)
}
return customStatus
}
func removeUnicodeSkinTone(unicodeString string) string {
skinToneDetectorRegex := regexp.MustCompile("-(1f3fb|1f3fc|1f3fd|1f3fe|1f3ff)")
skinToneLocations := skinToneDetectorRegex.FindStringIndex(unicodeString)
if len(skinToneLocations) == 0 {
return unicodeString
}
if _, count := model.GetEmojiNameFromUnicode(unicodeString); count > 0 {
return unicodeString
}
unicodeWithRemovedSkinTone := unicodeString[:skinToneLocations[0]] + unicodeString[skinToneLocations[1]:]
unicodeWithVariationSelector := unicodeString[:skinToneLocations[0]] + "-fe0f" + unicodeString[skinToneLocations[1]:]
if _, count := model.GetEmojiNameFromUnicode(unicodeWithRemovedSkinTone); count > 0 {
unicodeString = unicodeWithRemovedSkinTone
} else if _, count := model.GetEmojiNameFromUnicode(unicodeWithVariationSelector); count > 0 {
unicodeString = unicodeWithVariationSelector
}
return unicodeString
}

View File

@@ -0,0 +1,34 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package slashcommands
import (
"testing"
"github.com/mattermost/mattermost/server/public/model"
)
func TestGetCustomStatus(t *testing.T) {
for msg, expected := range map[string]model.CustomStatus{
"": {Emoji: model.DefaultCustomStatusEmoji, Text: ""},
"Hey": {Emoji: model.DefaultCustomStatusEmoji, Text: "Hey"},
":cactus: Hurt": {Emoji: "cactus", Text: "Hurt"},
"👅": {Emoji: "tongue", Text: ""},
"👅 Eating": {Emoji: "tongue", Text: "Eating"},
"💪🏻 Working out": {Emoji: "muscle_light_skin_tone", Text: "Working out"},
"👙 Swimming": {Emoji: "bikini", Text: "Swimming"},
"👙Swimming": {Emoji: model.DefaultCustomStatusEmoji, Text: "👙Swimming"},
"👍🏿 Okay": {Emoji: "+1_dark_skin_tone", Text: "Okay"},
"🤴🏾 Dark king": {Emoji: "prince_medium_dark_skin_tone", Text: "Dark king"},
"⛹🏾‍♀️ Playing basketball": {Emoji: "basketball_woman_medium_dark_skin_tone", Text: "Playing basketball"},
"🏋🏿‍♀️ Weightlifting": {Emoji: "weight_lifting_woman_dark_skin_tone", Text: "Weightlifting"},
"🏄 Surfing": {Emoji: "surfer", Text: "Surfing"},
"👨‍👨‍👦‍👦 Family": {Emoji: "family_man_man_boy_boy", Text: "Family"},
} {
actual := GetCustomStatus(msg)
if actual.Emoji != expected.Emoji || actual.Text != expected.Text {
t.Errorf("expected `%v`, got `%v`", expected, *actual)
}
}
}

View File

@@ -0,0 +1,41 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package slashcommands
import (
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/i18n"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/app"
)
type DndProvider struct {
}
const (
CmdDND = "dnd"
)
func init() {
app.RegisterCommandProvider(&DndProvider{})
}
func (*DndProvider) GetTrigger() string {
return CmdDND
}
func (*DndProvider) GetCommand(a *app.App, T i18n.TranslateFunc) *model.Command {
return &model.Command{
Trigger: CmdDND,
AutoComplete: true,
AutoCompleteDesc: T("api.command_dnd.desc"),
DisplayName: T("api.command_dnd.name"),
}
}
func (*DndProvider) DoCommand(a *app.App, rctx request.CTX, args *model.CommandArgs, message string) *model.CommandResponse {
a.SetStatusDoNotDisturb(args.UserId)
return &model.CommandResponse{ResponseType: model.CommandResponseTypeEphemeral, Text: args.T("api.command_dnd.success")}
}

View File

@@ -0,0 +1,98 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package slashcommands
import (
"strconv"
"strings"
"time"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/i18n"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/app"
)
var echoSem chan bool
type EchoProvider struct {
}
const (
CmdEcho = "echo"
)
func init() {
app.RegisterCommandProvider(&EchoProvider{})
}
func (*EchoProvider) GetTrigger() string {
return CmdEcho
}
func (*EchoProvider) GetCommand(a *app.App, T i18n.TranslateFunc) *model.Command {
return &model.Command{
Trigger: CmdEcho,
AutoComplete: true,
AutoCompleteDesc: T("api.command_echo.desc"),
AutoCompleteHint: T("api.command_echo.hint"),
DisplayName: T("api.command_echo.name"),
}
}
func (*EchoProvider) DoCommand(a *app.App, rctx request.CTX, args *model.CommandArgs, message string) *model.CommandResponse {
if message == "" {
return &model.CommandResponse{Text: args.T("api.command_echo.message.app_error"), ResponseType: model.CommandResponseTypeEphemeral}
}
maxThreads := 100
delay := 0
if endMsg := strings.LastIndex(message, "\""); string(message[0]) == "\"" && endMsg > 1 {
if checkDelay, err := strconv.Atoi(strings.Trim(message[endMsg:], " \"")); err == nil {
delay = checkDelay
}
message = message[1:endMsg]
} else if strings.Contains(message, " ") {
delayIdx := strings.LastIndex(message, " ")
delayStr := strings.Trim(message[delayIdx:], " ")
if checkDelay, err := strconv.Atoi(delayStr); err == nil {
delay = checkDelay
message = message[:delayIdx]
}
}
if delay > 10000 {
return &model.CommandResponse{Text: args.T("api.command_echo.delay.app_error"), ResponseType: model.CommandResponseTypeEphemeral}
}
if echoSem == nil {
// We want one additional thread allowed so we never reach channel lockup
echoSem = make(chan bool, maxThreads+1)
}
if len(echoSem) >= maxThreads {
return &model.CommandResponse{Text: args.T("api.command_echo.high_volume.app_error"), ResponseType: model.CommandResponseTypeEphemeral}
}
echoSem <- true
a.Srv().Go(func() {
defer func() { <-echoSem }()
post := &model.Post{}
post.ChannelId = args.ChannelId
post.RootId = args.RootId
post.Message = message
post.UserId = args.UserId
time.Sleep(time.Duration(delay) * time.Second)
if _, _, err := a.CreatePostMissingChannel(rctx, post, true, true); err != nil {
rctx.Logger().Error("Unable to create /echo post.", mlog.Err(err))
}
})
return &model.CommandResponse{}
}

View File

@@ -0,0 +1,95 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package slashcommands
import (
"encoding/json"
"strconv"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/i18n"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/app"
)
type ExpandProvider struct {
}
type CollapseProvider struct {
}
const (
CmdExpand = "expand"
CmdCollapse = "collapse"
)
func init() {
app.RegisterCommandProvider(&ExpandProvider{})
app.RegisterCommandProvider(&CollapseProvider{})
}
func (*ExpandProvider) GetTrigger() string {
return CmdExpand
}
func (*CollapseProvider) GetTrigger() string {
return CmdCollapse
}
func (*ExpandProvider) GetCommand(a *app.App, T i18n.TranslateFunc) *model.Command {
return &model.Command{
Trigger: CmdExpand,
AutoComplete: true,
AutoCompleteDesc: T("api.command_expand.desc"),
DisplayName: T("api.command_expand.name"),
}
}
func (*CollapseProvider) GetCommand(a *app.App, T i18n.TranslateFunc) *model.Command {
return &model.Command{
Trigger: CmdCollapse,
AutoComplete: true,
AutoCompleteDesc: T("api.command_collapse.desc"),
DisplayName: T("api.command_collapse.name"),
}
}
func (*ExpandProvider) DoCommand(a *app.App, rctx request.CTX, args *model.CommandArgs, message string) *model.CommandResponse {
return setCollapsePreference(a, args, false)
}
func (*CollapseProvider) DoCommand(a *app.App, rctx request.CTX, args *model.CommandArgs, message string) *model.CommandResponse {
return setCollapsePreference(a, args, true)
}
func setCollapsePreference(a *app.App, args *model.CommandArgs, isCollapse bool) *model.CommandResponse {
pref := model.Preference{
UserId: args.UserId,
Category: model.PreferenceCategoryDisplaySettings,
Name: model.PreferenceNameCollapseSetting,
Value: strconv.FormatBool(isCollapse),
}
if err := a.Srv().Store().Preference().Save(model.Preferences{pref}); err != nil {
return &model.CommandResponse{Text: args.T("api.command_expand_collapse.fail.app_error") + err.Error(), ResponseType: model.CommandResponseTypeEphemeral}
}
socketMessage := model.NewWebSocketEvent(model.WebsocketEventPreferenceChanged, "", "", args.UserId, nil, "")
prefJSON, err := json.Marshal(pref)
if err != nil {
return &model.CommandResponse{Text: args.T("api.marshal_error") + err.Error(), ResponseType: model.CommandResponseTypeEphemeral}
}
socketMessage.Add("preference", string(prefJSON))
a.Publish(socketMessage)
var rmsg string
if isCollapse {
rmsg = args.T("api.command_collapse.success")
} else {
rmsg = args.T("api.command_expand.success")
}
return &model.CommandResponse{ResponseType: model.CommandResponseTypeEphemeral, Text: rmsg}
}

View File

@@ -0,0 +1,120 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package slashcommands
import (
"path"
"strings"
"time"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/i18n"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/app"
"github.com/mattermost/mattermost/server/v8/platform/shared/filestore"
)
type ExportLinkProvider struct {
}
const (
CmdExportLink = "exportlink"
LatestExportMessage = "latest"
)
func init() {
app.RegisterCommandProvider(&ExportLinkProvider{})
}
func (*ExportLinkProvider) GetTrigger() string {
return CmdExportLink
}
func (*ExportLinkProvider) GetCommand(a *app.App, T i18n.TranslateFunc) *model.Command {
if !a.License().IsCloud() {
return nil
}
if !*a.Config().FileSettings.DedicatedExportStore {
return nil
}
b := a.ExportFileBackend()
_, ok := b.(filestore.FileBackendWithLinkGenerator)
if !ok {
return nil
}
return &model.Command{
Trigger: CmdExportLink,
AutoComplete: true,
AutoCompleteDesc: T("api.command_exportlink.desc"),
AutoCompleteHint: T("api.command_exportlink.hint", map[string]any{
"LatestMsg": LatestExportMessage,
}),
DisplayName: T("api.command_exportlink.name"),
}
}
func (*ExportLinkProvider) DoCommand(a *app.App, rctx request.CTX, args *model.CommandArgs, message string) *model.CommandResponse {
if !a.SessionHasPermissionTo(*rctx.Session(), model.PermissionManageSystem) {
return &model.CommandResponse{ResponseType: model.CommandResponseTypeEphemeral, Text: args.T("api.command_exportlink.permission.app_error")}
}
b := a.ExportFileBackend()
_, ok := b.(filestore.FileBackendWithLinkGenerator)
if !ok {
return &model.CommandResponse{ResponseType: model.CommandResponseTypeEphemeral, Text: args.T("api.command_exportlink.driver.app_error")}
}
file := ""
if message == LatestExportMessage {
files, err := b.ListDirectory(*a.Config().ExportSettings.Directory)
if err != nil {
return &model.CommandResponse{ResponseType: model.CommandResponseTypeEphemeral, Text: args.T("api.command_exportlink.list.app_error")}
}
if len(files) == 0 {
return &model.CommandResponse{ResponseType: model.CommandResponseTypeEphemeral, Text: args.T("api.command_exportlink.empty.app_error")}
}
latestFound := time.Time{}
for _, f := range files {
// find the latest file
if !strings.HasSuffix(f, "_export.zip") {
continue
}
t, err := b.FileModTime(f)
if err != nil {
rctx.Logger().Warn("Failed to get file mod time", mlog.String("file", f), mlog.Err(err))
continue
}
if t.After(latestFound) {
file = path.Base(f)
latestFound = t
}
}
}
if model.IsValidId(message) {
file = message + "_export.zip"
}
if file == "" {
file = message
}
if !strings.HasSuffix(file, "_export.zip") {
return &model.CommandResponse{ResponseType: model.CommandResponseTypeEphemeral, Text: args.T("api.command_exportlink.invalid.app_error")}
}
res, err := a.GeneratePresignURLForExport(file)
if err != nil {
return &model.CommandResponse{ResponseType: model.CommandResponseTypeEphemeral, Text: args.T("api.command_exportlink.presign.app_error")}
}
// return link
return &model.CommandResponse{ResponseType: model.CommandResponseTypeEphemeral, Text: args.T("api.command_exportlink.link.text", map[string]any{
"Link": res.URL,
"Expiration": res.Expiration.String(),
})}
}

View File

@@ -0,0 +1,159 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package slashcommands
import (
"fmt"
"strings"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/i18n"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/app"
)
type groupmsgProvider struct {
}
const (
CmdGroupMsg = "groupmsg"
)
func init() {
app.RegisterCommandProvider(&groupmsgProvider{})
}
func (*groupmsgProvider) GetTrigger() string {
return CmdGroupMsg
}
func (*groupmsgProvider) GetCommand(a *app.App, T i18n.TranslateFunc) *model.Command {
return &model.Command{
Trigger: CmdGroupMsg,
AutoComplete: true,
AutoCompleteDesc: T("api.command_groupmsg.desc"),
AutoCompleteHint: T("api.command_groupmsg.hint"),
DisplayName: T("api.command_groupmsg.name"),
}
}
func (*groupmsgProvider) DoCommand(a *app.App, rctx request.CTX, args *model.CommandArgs, message string) *model.CommandResponse {
targetUsers := map[string]*model.User{}
targetUsersSlice := []string{args.UserId}
invalidUsernames := []string{}
users, parsedMessage := groupMsgUsernames(message)
for _, username := range users {
username = strings.TrimSpace(username)
username = strings.TrimPrefix(username, "@")
targetUser, nErr := a.Srv().Store().User().GetByUsername(username)
if nErr != nil {
invalidUsernames = append(invalidUsernames, username)
continue
}
canSee, err := a.UserCanSeeOtherUser(rctx, args.UserId, targetUser.Id)
if err != nil {
return &model.CommandResponse{Text: args.T("api.command_groupmsg.fail.app_error"), ResponseType: model.CommandResponseTypeEphemeral}
}
if !canSee {
invalidUsernames = append(invalidUsernames, username)
continue
}
_, exists := targetUsers[targetUser.Id]
if !exists && targetUser.Id != args.UserId {
targetUsers[targetUser.Id] = targetUser
targetUsersSlice = append(targetUsersSlice, targetUser.Id)
}
}
if len(invalidUsernames) > 0 {
invalidUsersString := map[string]any{
"Users": "@" + strings.Join(invalidUsernames, ", @"),
}
return &model.CommandResponse{
Text: args.T("api.command_groupmsg.invalid_user.app_error", len(invalidUsernames), invalidUsersString),
ResponseType: model.CommandResponseTypeEphemeral,
}
}
if len(targetUsersSlice) == 2 {
return app.GetCommandProvider("msg").DoCommand(a, rctx, args, fmt.Sprintf("%s %s", targetUsers[targetUsersSlice[1]].Username, parsedMessage))
}
if len(targetUsersSlice) < model.ChannelGroupMinUsers {
minUsers := map[string]any{
"MinUsers": model.ChannelGroupMinUsers - 1,
}
return &model.CommandResponse{
Text: args.T("api.command_groupmsg.min_users.app_error", minUsers),
ResponseType: model.CommandResponseTypeEphemeral,
}
}
if len(targetUsersSlice) > model.ChannelGroupMaxUsers {
maxUsers := map[string]any{
"MaxUsers": model.ChannelGroupMaxUsers - 1,
}
return &model.CommandResponse{
Text: args.T("api.command_groupmsg.max_users.app_error", maxUsers),
ResponseType: model.CommandResponseTypeEphemeral,
}
}
var groupChannel *model.Channel
var channelErr *model.AppError
if a.HasPermissionTo(rctx, args.UserId, model.PermissionCreateGroupChannel) {
groupChannel, channelErr = a.CreateGroupChannel(rctx, targetUsersSlice, args.UserId)
if channelErr != nil {
rctx.Logger().Error(channelErr.Error())
return &model.CommandResponse{Text: args.T("api.command_groupmsg.group_fail.app_error"), ResponseType: model.CommandResponseTypeEphemeral}
}
} else {
groupChannel, channelErr = a.GetGroupChannel(rctx, targetUsersSlice)
if channelErr != nil {
return &model.CommandResponse{Text: args.T("api.command_groupmsg.permission.app_error"), ResponseType: model.CommandResponseTypeEphemeral}
}
}
if parsedMessage != "" {
post := &model.Post{}
post.Message = parsedMessage
post.ChannelId = groupChannel.Id
post.UserId = args.UserId
if _, _, err := a.CreatePostMissingChannel(rctx, post, true, true); err != nil {
return &model.CommandResponse{Text: args.T("api.command_groupmsg.fail.app_error"), ResponseType: model.CommandResponseTypeEphemeral}
}
}
team, err := a.GetTeam(args.TeamId)
if err != nil {
return &model.CommandResponse{Text: args.T("api.command_groupmsg.fail.app_error"), ResponseType: model.CommandResponseTypeEphemeral}
}
return &model.CommandResponse{GotoLocation: args.SiteURL + "/" + team.Name + "/channels/" + groupChannel.Name, Text: "", ResponseType: model.CommandResponseTypeEphemeral}
}
func groupMsgUsernames(message string) ([]string, string) {
result := []string{}
resultMessage := ""
for idx, part := range strings.Split(message, ",") {
clean := strings.TrimPrefix(strings.TrimSpace(part), "@")
split := strings.Fields(clean)
if len(split) > 0 {
result = append(result, split[0])
}
if len(split) > 1 {
splitted := strings.SplitN(message, ",", idx+1)
resultMessage = strings.TrimPrefix(strings.TrimSpace(splitted[len(splitted)-1]), "@")
resultMessage = strings.TrimSpace(strings.TrimPrefix(resultMessage, split[0]))
break
}
}
return result, resultMessage
}

View File

@@ -0,0 +1,121 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package slashcommands
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/i18n"
)
func TestGroupMsgUsernames(t *testing.T) {
assert := assert.New(t)
users, parsedMessage := groupMsgUsernames("")
assert.Len(users, 0)
assert.Empty(parsedMessage)
users, parsedMessage = groupMsgUsernames("test")
assert.Len(users, 1)
assert.Empty(parsedMessage)
users, parsedMessage = groupMsgUsernames("test1, test2, test3 , test4")
assert.Len(users, 4)
assert.Empty(parsedMessage)
users, parsedMessage = groupMsgUsernames("test1, test2 message with spaces")
assert.Len(users, 2)
assert.Equal(parsedMessage, "message with spaces", "error parsing message")
users, parsedMessage = groupMsgUsernames("test1, test2 message with, comma")
assert.Len(users, 2)
assert.Equal(parsedMessage, "message with, comma", "error parsing messages with comma")
users, parsedMessage = groupMsgUsernames("test1,,,test2")
assert.Len(users, 2)
assert.Empty(parsedMessage)
users, parsedMessage = groupMsgUsernames(" test1, test2 other message ")
assert.Len(users, 2)
assert.Equal(parsedMessage, "other message", "error parsing strange usage of spaces")
users, _ = groupMsgUsernames(" test1, test2,,123,@321,+123")
assert.Len(users, 5)
assert.Equal(users[0], "test1")
assert.Equal(users[1], "test2")
assert.Equal(users[2], "123")
assert.Equal(users[3], "321")
assert.Equal(users[4], "+123")
assert.Equal(parsedMessage, "other message", "error parsing different types of users")
}
func TestGroupMsgProvider(t *testing.T) {
th := setup(t).initBasic(t)
user3 := th.createUser(t)
targetUsers := "@" + th.BasicUser2.Username + ",@" + user3.Username + " "
team := th.createTeam(t)
th.linkUserToTeam(t, th.BasicUser, team)
cmd := &groupmsgProvider{}
th.removePermissionFromRole(t, model.PermissionCreateGroupChannel.Id, model.SystemUserRoleId)
t.Run("Check without permission to create a GM channel.", func(t *testing.T) {
resp := cmd.DoCommand(th.App, th.Context, &model.CommandArgs{
T: i18n.IdentityTfunc(),
SiteURL: "http://test.url",
TeamId: team.Id,
UserId: th.BasicUser.Id,
}, targetUsers+"hello")
assert.Equal(t, "api.command_groupmsg.permission.app_error", resp.Text)
assert.Equal(t, "", resp.GotoLocation)
})
th.addPermissionToRole(t, model.PermissionCreateGroupChannel.Id, model.SystemUserRoleId)
t.Run("Check without permissions to view a user in the list.", func(t *testing.T) {
th.removePermissionFromRole(t, model.PermissionViewMembers.Id, model.SystemUserRoleId)
t.Cleanup(func() { th.addPermissionToRole(t, model.PermissionViewMembers.Id, model.SystemUserRoleId) })
resp := cmd.DoCommand(th.App, th.Context, &model.CommandArgs{
T: i18n.IdentityTfunc(),
SiteURL: "http://test.url",
TeamId: team.Id,
UserId: th.BasicUser.Id,
}, targetUsers+"hello")
assert.Equal(t, "api.command_groupmsg.invalid_user.app_error", resp.Text)
assert.Equal(t, "", resp.GotoLocation)
})
t.Run("Check with permission to create a GM channel.", func(t *testing.T) {
resp := cmd.DoCommand(th.App, th.Context, &model.CommandArgs{
T: i18n.IdentityTfunc(),
SiteURL: "http://test.url",
TeamId: team.Id,
UserId: th.BasicUser.Id,
}, targetUsers+"hello")
channelName := model.GetGroupNameFromUserIds([]string{th.BasicUser.Id, th.BasicUser2.Id, user3.Id})
assert.Equal(t, "", resp.Text)
assert.Equal(t, "http://test.url/"+team.Name+"/channels/"+channelName, resp.GotoLocation)
})
t.Run("Check without permission to post to an existing GM channel.", func(t *testing.T) {
resp := cmd.DoCommand(th.App, th.Context, &model.CommandArgs{
T: i18n.IdentityTfunc(),
SiteURL: "http://test.url",
TeamId: team.Id,
UserId: th.BasicUser.Id,
}, targetUsers+"hello")
channelName := model.GetGroupNameFromUserIds([]string{th.BasicUser.Id, th.BasicUser2.Id, user3.Id})
assert.Equal(t, "", resp.Text)
assert.Equal(t, "http://test.url/"+team.Name+"/channels/"+channelName, resp.GotoLocation)
})
}

View File

@@ -0,0 +1,50 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package slashcommands
import (
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/i18n"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/app"
)
type HelpProvider struct {
}
const (
CmdHelp = "help"
)
func init() {
app.RegisterCommandProvider(&HelpProvider{})
}
func (h *HelpProvider) GetTrigger() string {
return CmdHelp
}
func (h *HelpProvider) GetCommand(a *app.App, T i18n.TranslateFunc) *model.Command {
return &model.Command{
Trigger: CmdHelp,
AutoComplete: true,
AutoCompleteDesc: T("api.command_help.desc"),
DisplayName: T("api.command_help.name"),
}
}
func (h *HelpProvider) DoCommand(a *app.App, rctx request.CTX, args *model.CommandArgs, message string) *model.CommandResponse {
helpLink := *a.Config().SupportSettings.HelpLink
if helpLink == "" {
helpLink = model.SupportSettingsDefaultHelpLink
}
return &model.CommandResponse{
ResponseType: model.CommandResponseTypeEphemeral,
Text: args.T("api.command_help.success", map[string]any{
"HelpLink": helpLink,
}),
}
}

View File

@@ -0,0 +1,320 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package slashcommands
import (
"strings"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/i18n"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/app"
)
type InviteProvider struct {
}
const (
CmdInvite = "invite"
)
type UserError int64
const (
NoError UserError = iota
UserInChannel
UserNotInTeam
IsConstrained
Unknown
)
func init() {
app.RegisterCommandProvider(&InviteProvider{})
}
func (*InviteProvider) GetTrigger() string {
return CmdInvite
}
func (*InviteProvider) GetCommand(a *app.App, T i18n.TranslateFunc) *model.Command {
return &model.Command{
Trigger: CmdInvite,
AutoComplete: true,
AutoCompleteDesc: T("api.command_invite.desc"),
AutoCompleteHint: T("api.command_invite.hint"),
DisplayName: T("api.command_invite.name"),
}
}
func (i *InviteProvider) DoCommand(a *app.App, rctx request.CTX, args *model.CommandArgs, message string) *model.CommandResponse {
return &model.CommandResponse{
Text: i.doCommand(a, rctx, args, message),
ResponseType: model.CommandResponseTypeEphemeral,
}
}
func (i *InviteProvider) doCommand(a *app.App, rctx request.CTX, args *model.CommandArgs, message string) string {
if message == "" {
return args.T("api.command_invite.missing_message.app_error")
}
resps := &[]string{}
targetUsers, targetChannels, resp := i.parseMessage(a, rctx, args, resps, message)
if resp != "" {
return resp
}
// Verify that the inviter has permissions to invite users to the every channel.
targetChannels = i.checkPermissions(a, rctx, args, resps, targetUsers[0], targetChannels)
// track errors returned for various users.
differentChannels := make(map[string][]string)
nonTeamUsers := make(map[string][]string)
channelConstrained := make([]string, 0, 1)
usersInChannel := make([]string, 0, 1)
errorUsers := make([]string, 0, 1)
for _, targetChannel := range targetChannels {
var targetTeamDisplay string
for _, targetUser := range targetUsers {
userError := i.addUserToChannel(a, rctx, args, targetUser, targetChannel)
if userError == NoError {
if args.ChannelId != targetChannel.Id {
differentChannels[targetChannel.Name] = append(differentChannels[targetChannel.Name], targetUser.Username)
}
} else if userError == UserNotInTeam {
if targetTeamDisplay == "" {
targetTeam, err := a.GetTeam(targetChannel.TeamId)
if err != nil {
targetTeamDisplay = "unknown"
} else {
targetTeamDisplay = targetTeam.DisplayName
}
}
nonTeamUsers[targetTeamDisplay] = append(nonTeamUsers[targetTeamDisplay], targetUser.Username)
} else if userError == IsConstrained {
channelConstrained = append(channelConstrained, targetUser.Username)
} else if userError == UserInChannel {
usersInChannel = append(usersInChannel, targetUser.Username)
} else {
errorUsers = append(errorUsers, targetUser.Username)
}
}
}
if len(usersInChannel) > 0 {
if len(usersInChannel) > 10 {
*resps = append(*resps,
args.T("api.command_invite.user_already_in_channel.overflow", map[string]any{
"FirstUser": "@" + usersInChannel[0],
"Others": len(usersInChannel) - 1,
}),
)
} else {
usersString := map[string]any{
"User": "@" + strings.Join(usersInChannel, ", @"),
}
*resps = append(*resps,
args.T("api.command_invite.user_already_in_channel.app_error", len(usersInChannel), usersString),
)
}
}
if len(differentChannels) > 0 {
for k, v := range differentChannels {
if len(v) > 10 {
*resps = append(*resps,
args.T("api.command_invite.successOverflow", map[string]any{
"FirstUser": "@" + v[0],
"Others": len(v) - 1,
"Channel": k,
}),
)
} else {
usersString := map[string]any{
"User": "@" + strings.Join(v, ", @"),
"Channel": k,
}
*resps = append(*resps,
args.T("api.command_invite.success", usersString),
)
}
}
}
if len(nonTeamUsers) > 0 {
for k, v := range nonTeamUsers {
if len(v) > 10 {
*resps = append(*resps,
args.T("api.command_invite.user_not_in_team.messageOverflow", map[string]any{
"FirstUser": "@" + v[0],
"Others": len(v) - 1,
"Team": k,
}),
)
} else {
usersString := map[string]any{
"Users": "@" + strings.Join(v, ", @"),
"Team": k,
}
*resps = append(*resps,
args.T("api.command_invite.user_not_in_team.app_error", usersString),
)
}
}
}
if len(channelConstrained) > 0 {
*resps = append(*resps,
args.T("api.command_invite.channel_constrained_user_denied"),
)
}
if len(errorUsers) > 0 {
*resps = append(*resps,
args.T("api.command_invite.fail.app_error"),
)
}
if len(*resps) > 0 {
return strings.Join(*resps, "\n")
}
return ""
}
func (i *InviteProvider) getUsersFromMentionName(a *app.App, mentionName string) ([]*model.User, *model.Group) {
userProfile, err := a.Srv().Store().User().GetByUsername(mentionName)
if err == nil && userProfile.DeleteAt == 0 {
return []*model.User{userProfile}, nil
}
group, appErr := a.GetGroupByName(mentionName, model.GroupSearchOpts{FilterAllowReference: true})
if appErr != nil || group == nil {
return nil, nil
}
members, appErr := a.GetGroupMemberUsers(group.Id)
if appErr != nil {
return nil, nil
}
return members, group
}
func (i *InviteProvider) parseMessage(a *app.App, rctx request.CTX, args *model.CommandArgs, resps *[]string, message string) ([]*model.User, []*model.Channel, string) {
splitMessage := strings.Split(message, " ")
targetUsers := make([]*model.User, 0, 1)
targetChannels := make([]*model.Channel, 0)
for j, msg := range splitMessage {
if msg == "" {
continue
}
if msg[0] == '@' || (msg[0] != '~' && j == 0) {
targetMentionName := strings.TrimPrefix(msg, "@")
users, _ := i.getUsersFromMentionName(a, targetMentionName)
if len(users) == 0 {
*resps = append(*resps, args.T("api.command_invite.missing_user.app_error", map[string]any{
"User": targetMentionName,
}))
continue
}
targetUsers = append(targetUsers, users...)
} else {
targetChannelName := strings.TrimPrefix(msg, "~")
channelToJoin, err := a.GetChannelByName(rctx, targetChannelName, args.TeamId, false)
if err != nil {
*resps = append(*resps, args.T("api.command_invite.channel.error", map[string]any{
"Channel": targetChannelName,
}))
continue
}
targetChannels = append(targetChannels, channelToJoin)
}
}
if len(targetUsers) == 0 {
if len(*resps) != 0 {
return nil, nil, strings.Join(*resps, "\n")
}
return nil, nil, args.T("api.command_invite.missing_message.app_error")
}
if len(targetChannels) == 0 {
if len(*resps) != 0 {
return nil, nil, strings.Join(*resps, "\n")
}
channelToJoin, err := a.GetChannel(rctx, args.ChannelId)
if err != nil {
return nil, nil, args.T("api.command_invite.channel.app_error")
}
targetChannels = append(targetChannels, channelToJoin)
}
return targetUsers, targetChannels, ""
}
func (i *InviteProvider) checkPermissions(a *app.App, rctx request.CTX, args *model.CommandArgs, resps *[]string, targetUser *model.User, targetChannels []*model.Channel) []*model.Channel {
var err *model.AppError
validChannels := make([]*model.Channel, 0, len(targetChannels))
for _, targetChannel := range targetChannels {
switch targetChannel.Type {
case model.ChannelTypeOpen:
if ok, _ := a.HasPermissionToChannel(rctx, args.UserId, targetChannel.Id, model.PermissionManagePublicChannelMembers); !ok {
*resps = append(*resps, args.T("api.command_invite.permission.app_error", map[string]any{
"User": targetUser.Username,
"Channel": targetChannel.Name,
}))
continue
}
case model.ChannelTypePrivate:
if ok, _ := a.HasPermissionToChannel(rctx, args.UserId, targetChannel.Id, model.PermissionManagePrivateChannelMembers); !ok {
if _, err = a.GetChannelMember(rctx, targetChannel.Id, args.UserId); err == nil {
// User doing the inviting is a member of the channel.
*resps = append(*resps, args.T("api.command_invite.permission.app_error", map[string]any{
"User": targetUser.Username,
"Channel": targetChannel.Name,
}))
continue
}
// User doing the inviting is *not* a member of the channel.
*resps = append(*resps, args.T("api.command_invite.private_channel.app_error", map[string]any{
"Channel": targetChannel.Name,
}))
continue
}
default:
*resps = append(*resps, args.T("api.command_invite.directchannel.app_error"))
continue
}
validChannels = append(validChannels, targetChannel)
}
return validChannels
}
func (i *InviteProvider) addUserToChannel(a *app.App, rctx request.CTX, args *model.CommandArgs, userProfile *model.User, channelToJoin *model.Channel) UserError {
// Check if user is already in the channel
_, err := a.GetChannelMember(rctx, channelToJoin.Id, userProfile.Id)
if err == nil {
return UserInChannel
}
if _, err = a.AddChannelMember(rctx, userProfile.Id, channelToJoin, app.ChannelMemberOpts{UserRequestorID: args.UserId}); err != nil {
if err.Id == "api.channel.add_members.user_denied" {
return IsConstrained
} else if err.Id == "app.team.get_member.missing.app_error" ||
err.Id == "api.channel.add_user.to.channel.failed.deleted.app_error" {
return UserNotInTeam
}
rctx.Logger().Warn("addUserToChannel had unexpected error.", mlog.String("user_id", userProfile.Id), mlog.Err(err))
return Unknown
}
return NoError
}

View File

@@ -0,0 +1,88 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package slashcommands
import (
"strings"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/i18n"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/app"
)
type InvitePeopleProvider struct {
}
const (
CmdInvitePeople = "invite_people"
)
func init() {
app.RegisterCommandProvider(&InvitePeopleProvider{})
}
func (*InvitePeopleProvider) GetTrigger() string {
return CmdInvitePeople
}
func (*InvitePeopleProvider) GetCommand(a *app.App, T i18n.TranslateFunc) *model.Command {
autoComplete := true
if !*a.Config().EmailSettings.SendEmailNotifications || !*a.Config().TeamSettings.EnableUserCreation || !*a.Config().ServiceSettings.EnableEmailInvitations {
autoComplete = false
}
return &model.Command{
Trigger: CmdInvitePeople,
AutoComplete: autoComplete,
AutoCompleteDesc: T("api.command.invite_people.desc"),
AutoCompleteHint: T("api.command.invite_people.hint"),
DisplayName: T("api.command.invite_people.name"),
}
}
func parseEmailList(message string) []string {
var emails []string
for token := range strings.FieldsSeq(message) {
token = strings.Trim(token, ",")
if strings.Contains(token, "@") {
emails = append(emails, token)
}
}
return emails
}
func (*InvitePeopleProvider) DoCommand(a *app.App, rctx request.CTX, args *model.CommandArgs, message string) *model.CommandResponse {
if !a.HasPermissionToTeam(rctx, args.UserId, args.TeamId, model.PermissionInviteUser) {
return &model.CommandResponse{Text: args.T("api.command_invite_people.permission.app_error"), ResponseType: model.CommandResponseTypeEphemeral}
}
if !a.HasPermissionToTeam(rctx, args.UserId, args.TeamId, model.PermissionAddUserToTeam) {
return &model.CommandResponse{Text: args.T("api.command_invite_people.permission.app_error"), ResponseType: model.CommandResponseTypeEphemeral}
}
if !*a.Config().EmailSettings.SendEmailNotifications {
return &model.CommandResponse{ResponseType: model.CommandResponseTypeEphemeral, Text: args.T("api.command.invite_people.email_off")}
}
if !*a.Config().TeamSettings.EnableUserCreation {
return &model.CommandResponse{ResponseType: model.CommandResponseTypeEphemeral, Text: args.T("api.command.invite_people.invite_off")}
}
if !*a.Config().ServiceSettings.EnableEmailInvitations {
return &model.CommandResponse{ResponseType: model.CommandResponseTypeEphemeral, Text: args.T("api.command.invite_people.email_invitations_off")}
}
emailList := parseEmailList(message)
if len(emailList) == 0 {
return &model.CommandResponse{ResponseType: model.CommandResponseTypeEphemeral, Text: args.T("api.command.invite_people.no_email")}
}
if err := a.InviteNewUsersToTeam(rctx, emailList, args.TeamId, args.UserId); err != nil {
rctx.Logger().Error(err.Error())
return &model.CommandResponse{ResponseType: model.CommandResponseTypeEphemeral, Text: args.T("api.command.invite_people.fail")}
}
return &model.CommandResponse{ResponseType: model.CommandResponseTypeEphemeral, Text: args.T("api.command.invite_people.sent")}
}

View File

@@ -0,0 +1,93 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package slashcommands
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
)
func TestParseEmailList(t *testing.T) {
tests := []struct {
name string
input string
expected []string
}{
{
name: "single valid email",
input: "user@example.com",
expected: []string{"user@example.com"},
},
{
name: "multiple valid emails",
input: "a@example.com b@example.com",
expected: []string{"a@example.com", "b@example.com"},
},
{
name: "trailing commas stripped",
input: "a@example.com, b@example.com,",
expected: []string{"a@example.com", "b@example.com"},
},
{
name: "non-email tokens filtered out",
input: "notanemail a@example.com alsoinvalid",
expected: []string{"a@example.com"},
},
{
name: "comma immediately after email treated as one token",
input: "a@example.com,b@example.com",
expected: []string{"a@example.com,b@example.com"},
},
{
name: "empty input",
input: "",
expected: nil,
},
{
name: "all tokens invalid",
input: "notanemail alsoinvalid",
expected: nil,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result := parseEmailList(tc.input)
require.Equal(t, tc.expected, result)
})
}
}
func TestInvitePeopleProvider(t *testing.T) {
th := setup(t).initBasic(t)
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.EmailSettings.SendEmailNotifications = true
*cfg.ServiceSettings.EnableEmailInvitations = true
})
cmd := InvitePeopleProvider{}
notTeamUser := th.createUser(t)
// Test without required permissions
args := &model.CommandArgs{
T: func(s string, args ...any) string { return s },
ChannelId: th.BasicChannel.Id,
TeamId: th.BasicTeam.Id,
UserId: notTeamUser.Id,
}
actual := cmd.DoCommand(th.App, th.Context, args, model.NewId()+"@simulator.amazonses.com")
assert.Equal(t, "api.command_invite_people.permission.app_error", actual.Text)
// Test with required permissions.
args.UserId = th.BasicUser.Id
actual = cmd.DoCommand(th.App, th.Context, args, model.NewId()+"@simulator.amazonses.com")
assert.Equal(t, "api.command.invite_people.sent", actual.Text)
}

View File

@@ -0,0 +1,341 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package slashcommands
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/channels/app"
)
func TestInviteProvider(t *testing.T) {
th := setup(t).initBasic(t)
inviteProvider := InviteProvider{}
args := &model.CommandArgs{
T: func(s string, args ...any) string { return s },
ChannelId: th.BasicChannel.Id,
TeamId: th.BasicTeam.Id,
UserId: th.BasicUser.Id,
}
runCmd := func(msg string, expected string) {
actual := inviteProvider.DoCommand(th.App, th.Context, args, msg).Text
assert.Equal(t, expected, actual)
}
checkIsMember := func(channelID, userID string) {
_, channelMemberErr := th.App.GetChannelMember(th.Context, channelID, userID)
require.Nil(t, channelMemberErr, "Failed to add user to channel")
}
checkIsNotMember := func(channelID, userID string) {
_, channelMemberErr := th.App.GetChannelMember(th.Context, channelID, userID)
require.NotNil(t, channelMemberErr, "Failed to add user to channel")
}
t.Run("try to add missing user and channel in the command", func(t *testing.T) {
msg := ""
runCmd(msg, "api.command_invite.missing_message.app_error")
})
t.Run("user added in the current channel", func(t *testing.T) {
msg := th.BasicUser2.Username
runCmd(msg, "")
checkIsMember(th.BasicChannel.Id, th.BasicUser2.Id)
})
t.Run("add user to another channel not the current", func(t *testing.T) {
channel := th.createChannel(t, th.BasicTeam, model.ChannelTypeOpen)
msg := "@" + th.BasicUser2.Username + " ~" + channel.Name + " "
runCmd(msg, "api.command_invite.success")
checkIsMember(channel.Id, th.BasicUser2.Id)
})
t.Run("add a user to a private channel", func(t *testing.T) {
privateChannel := th.createChannel(t, th.BasicTeam, model.ChannelTypePrivate)
msg := "@" + th.BasicUser2.Username + " ~" + privateChannel.Name
runCmd(msg, "api.command_invite.success")
checkIsMember(privateChannel.Id, th.BasicUser2.Id)
})
t.Run("add multiple users to multiple channels", func(t *testing.T) {
anotherUser := th.createUser(t)
th.linkUserToTeam(t, anotherUser, th.BasicTeam)
channel1 := th.createChannel(t, th.BasicTeam, model.ChannelTypeOpen)
channel2 := th.createChannel(t, th.BasicTeam, model.ChannelTypeOpen)
msg := "@" + th.BasicUser2.Username + " @" + anotherUser.Username + " ~" + channel1.Name + " ~" + channel2.Name
expected := "api.command_invite.success\napi.command_invite.success"
runCmd(msg, expected)
checkIsMember(channel1.Id, th.BasicUser2.Id)
checkIsMember(channel2.Id, th.BasicUser2.Id)
checkIsMember(channel1.Id, anotherUser.Id)
checkIsMember(channel2.Id, anotherUser.Id)
})
t.Run("adds multiple users even when some are invalid or already members", func(t *testing.T) {
channel := th.createChannel(t, th.BasicTeam, model.ChannelTypeOpen)
userAlreadyInChannel := th.createUser(t)
th.linkUserToTeam(t, userAlreadyInChannel, th.BasicTeam)
th.addUserToChannel(t, userAlreadyInChannel, channel)
userInTeam := th.createUser(t)
th.linkUserToTeam(t, userInTeam, th.BasicTeam)
userNotInTeam := th.createUser(t)
msg := "@invalidUser123 @" + userAlreadyInChannel.Username + " @" + userInTeam.Username + " @" + userNotInTeam.Username + " ~" + channel.Name
expected := "api.command_invite.missing_user.app_error\n"
expected += "api.command_invite.user_already_in_channel.app_error\n"
expected += "api.command_invite.success\n"
expected += "api.command_invite.user_not_in_team.app_error"
runCmd(msg, expected)
checkIsMember(channel.Id, userInTeam.Id)
})
t.Run("try to add a user to a direct channel", func(t *testing.T) {
anotherUser := th.createUser(t)
th.linkUserToTeam(t, anotherUser, th.BasicTeam)
directChannel := th.createDmChannel(t, th.BasicUser2)
msg := "@" + anotherUser.Username + " ~" + directChannel.Name
runCmd(msg, "api.command_invite.directchannel.app_error")
checkIsNotMember(directChannel.Id, anotherUser.Id)
})
t.Run("try to add a user to an invalid channel", func(t *testing.T) {
msg := "@" + th.BasicUser2.Username + " wrongchannel1"
runCmd(msg, "api.command_invite.channel.error")
})
t.Run("try to add a user using channel's display name", func(t *testing.T) {
channel := th.createChannel(t, th.BasicTeam, model.ChannelTypeOpen)
msg := "@" + th.BasicUser2.Username + " ~" + channel.DisplayName
runCmd(msg, "api.command_invite.channel.error")
checkIsNotMember(channel.Id, th.BasicUser2.Id)
})
t.Run("try add invalid user to current channel", func(t *testing.T) {
msg := "@invalidUser123"
runCmd(msg, "api.command_invite.missing_user.app_error")
})
t.Run("invalid user to current channel without @", func(t *testing.T) {
msg := "invalidUser123"
runCmd(msg, "api.command_invite.missing_user.app_error")
})
t.Run("try to add a user which is not part of the team", func(t *testing.T) {
anotherUser := th.createUser(t)
// Do not add user to the team
msg := anotherUser.Username
runCmd(msg, "api.command_invite.user_not_in_team.app_error")
})
t.Run("try to add a user not part of the group to a group channel", func(t *testing.T) {
groupChannel := th.createChannel(t, th.BasicTeam, model.ChannelTypePrivate)
_, err := th.App.AddChannelMember(th.Context, th.BasicUser.Id, groupChannel, app.ChannelMemberOpts{})
require.Nil(t, err)
groupChannel.GroupConstrained = new(true)
groupChannel, _ = th.App.UpdateChannel(th.Context, groupChannel)
msg := "@" + th.BasicUser2.Username + " ~" + groupChannel.Name
runCmd(msg, "api.command_invite.channel_constrained_user_denied")
checkIsNotMember(groupChannel.Id, th.BasicUser2.Id)
})
t.Run("try to add a user to a private channel with no permission", func(t *testing.T) {
anotherUser := th.createUser(t)
th.linkUserToTeam(t, anotherUser, th.BasicTeam)
privateChannel := th.createChannelWithAnotherUser(t, th.BasicTeam, model.ChannelTypePrivate, th.BasicUser2.Id)
msg := "@" + anotherUser.Username + " ~" + privateChannel.Name
runCmd(msg, "api.command_invite.private_channel.app_error")
checkIsNotMember(privateChannel.Id, anotherUser.Id)
})
t.Run("try to add a deleted user to a public channel", func(t *testing.T) {
channel := th.createChannel(t, th.BasicTeam, model.ChannelTypeOpen)
deactivatedUser := th.createUser(t)
_, appErr := th.App.UpdateActive(th.Context, deactivatedUser, false)
require.Nil(t, appErr)
msg := "@" + deactivatedUser.Username + " ~" + channel.Name
runCmd(msg, "api.command_invite.missing_user.app_error")
checkIsNotMember(channel.Id, deactivatedUser.Id)
})
t.Run("add bot to a public channel", func(t *testing.T) {
bot, appErr := th.App.CreateBot(th.Context, &model.Bot{Username: "bot_" + model.NewId(), OwnerId: th.BasicUser2.Id})
require.Nil(t, appErr)
_, _, appErr = th.App.AddUserToTeam(th.Context, th.BasicTeam.Id, bot.UserId, th.BasicUser2.Id)
require.Nil(t, appErr)
msg := "@" + bot.Username
runCmd(msg, "")
checkIsMember(th.BasicChannel.Id, bot.UserId)
})
t.Run("try to add bot to a public channel without being a member", func(t *testing.T) {
bot, appErr := th.App.CreateBot(th.Context, &model.Bot{Username: "bot_" + model.NewId(), OwnerId: th.BasicUser2.Id})
require.Nil(t, appErr)
// Do not add to the team
msg := "@" + bot.Username
runCmd(msg, "api.command_invite.user_not_in_team.app_error")
checkIsNotMember(th.BasicChannel.Id, bot.UserId)
})
t.Run("try to add bot removed from a team to a public channel", func(t *testing.T) {
bot, appErr := th.App.CreateBot(th.Context, &model.Bot{Username: "bot_" + model.NewId(), OwnerId: th.BasicUser2.Id})
require.Nil(t, appErr)
_, _, appErr = th.App.AddUserToTeam(th.Context, th.BasicTeam.Id, bot.UserId, th.BasicUser2.Id)
require.Nil(t, appErr)
appErr = th.App.RemoveUserFromTeam(th.Context, th.BasicTeam.Id, bot.UserId, th.BasicUser2.Id)
require.Nil(t, appErr)
msg := "@" + bot.Username
runCmd(msg, "api.command_invite.user_not_in_team.app_error")
checkIsNotMember(th.BasicChannel.Id, bot.UserId)
})
}
func TestInviteGroup(t *testing.T) {
th := setup(t).initBasic(t)
th.BasicTeam.GroupConstrained = new(true)
var err *model.AppError
_, _ = th.App.AddTeamMember(th.Context, th.BasicTeam.Id, th.BasicUser.Id)
_, err = th.App.AddTeamMember(th.Context, th.BasicTeam.Id, th.BasicUser2.Id)
require.Nil(t, err)
th.BasicTeam, _ = th.App.UpdateTeam(th.BasicTeam)
privateChannel := th.createChannel(t, th.BasicTeam, model.ChannelTypePrivate)
groupChannelUser1 := "@" + th.BasicUser.Username + " ~" + privateChannel.Name
groupChannelUser2 := "@" + th.BasicUser2.Username + " ~" + privateChannel.Name
basicUser3 := th.createUser(t)
groupChannelUser3 := "@" + basicUser3.Username + " ~" + privateChannel.Name
InviteP := InviteProvider{}
args := &model.CommandArgs{
T: func(s string, args ...any) string { return s },
ChannelId: th.BasicChannel.Id,
TeamId: th.BasicTeam.Id,
UserId: th.BasicUser.Id,
}
tests := []struct {
desc string
expected string
msg string
}{
{
desc: "try to add an existing user part of the group to a group channel",
expected: "api.command_invite.user_already_in_channel.app_error",
msg: groupChannelUser1,
},
{
desc: "try to add a user part of the group to a group channel",
expected: "api.command_invite.success",
msg: groupChannelUser2,
},
{
desc: "try to add a user NOT part of the group to a group channel",
expected: "api.command_invite.user_not_in_team.app_error",
msg: groupChannelUser3,
},
}
for _, test := range tests {
t.Run(test.desc, func(t *testing.T) {
actual := InviteP.DoCommand(th.App, th.Context, args, test.msg).Text
assert.Equal(t, test.expected, actual)
})
}
}
func TestUserGroups(t *testing.T) {
th := setup(t).initBasic(t)
privateChannel := th.createChannel(t, th.BasicTeam, model.ChannelTypePrivate)
id := model.NewId()
teamGroup, err := th.App.CreateGroup(&model.Group{
DisplayName: "dn_" + id,
Name: new("name" + id),
Source: model.GroupSourceCustom,
Description: "description_" + id,
AllowReference: true,
// MemberIDs: []string{th.BasicUser2.Id},
})
assert.Nil(t, err)
teamGroupCommand := "@" + *teamGroup.Name + " ~" + privateChannel.Name
// th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuProfessional))
groupMembers, upsertErr := th.App.UpsertGroupMembers(teamGroup.Id, []string{th.BasicUser2.Id})
require.Nil(t, upsertErr)
assert.Len(t, groupMembers, 1)
basicUser3 := th.createUser(t)
basicUser4 := th.createUser(t)
id2 := model.NewId()
nonTeamGroup, err := th.App.CreateGroup(&model.Group{
DisplayName: "dn_" + id2,
Name: new("name" + id2),
Source: model.GroupSourceCustom,
Description: "description_" + id2,
AllowReference: true,
// MemberIDs: []string{basicUser3.Id, basicUser4.Id},
})
assert.Nil(t, err)
nonTeamGroupCommand := "@" + *nonTeamGroup.Name + " ~" + privateChannel.Name
nonTeamGroupMembers, upsertErr := th.App.UpsertGroupMembers(nonTeamGroup.Id, []string{basicUser3.Id, basicUser4.Id})
require.Nil(t, upsertErr)
assert.Len(t, nonTeamGroupMembers, 2)
InviteP := InviteProvider{}
args := &model.CommandArgs{
T: func(s string, args ...any) string { return s },
ChannelId: th.BasicChannel.Id,
TeamId: th.BasicTeam.Id,
UserId: th.BasicUser.Id,
}
tests := []struct {
desc string
expected string
msg string
}{
{
desc: "try to add an new group of users ",
expected: "api.command_invite.success",
msg: teamGroupCommand,
},
{
desc: "try to add existing users",
expected: "api.command_invite.user_already_in_channel.app_error",
msg: teamGroupCommand,
},
{
desc: "try to add a user NOT part of the team",
expected: "api.command_invite.user_not_in_team.app_error",
msg: nonTeamGroupCommand,
},
}
for _, test := range tests {
t.Run(test.desc, func(t *testing.T) {
actual := InviteP.DoCommand(th.App, th.Context, args, test.msg).Text
assert.Equal(t, test.expected, actual)
})
}
}

View File

@@ -0,0 +1,79 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package slashcommands
import (
"strings"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/i18n"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/app"
)
type JoinProvider struct {
}
const (
CmdJoin = "join"
)
func init() {
app.RegisterCommandProvider(&JoinProvider{})
}
func (*JoinProvider) GetTrigger() string {
return CmdJoin
}
func (*JoinProvider) GetCommand(a *app.App, T i18n.TranslateFunc) *model.Command {
return &model.Command{
Trigger: CmdJoin,
AutoComplete: true,
AutoCompleteDesc: T("api.command_join.desc"),
AutoCompleteHint: T("api.command_join.hint"),
DisplayName: T("api.command_join.name"),
}
}
func (*JoinProvider) DoCommand(a *app.App, rctx request.CTX, args *model.CommandArgs, message string) *model.CommandResponse {
channelName := strings.ToLower(message)
if strings.HasPrefix(message, "~") {
channelName = message[1:]
}
channel, err := a.Srv().Store().Channel().GetByName(args.TeamId, channelName, true)
if err != nil {
return &model.CommandResponse{Text: args.T("api.command_join.list.app_error"), ResponseType: model.CommandResponseTypeEphemeral}
}
if channel.Name != channelName {
return &model.CommandResponse{ResponseType: model.CommandResponseTypeEphemeral, Text: args.T("api.command_join.missing.app_error")}
}
switch channel.Type {
case model.ChannelTypeOpen:
if ok, _ := a.HasPermissionToChannel(rctx, args.UserId, channel.Id, model.PermissionJoinPublicChannels); !ok {
return &model.CommandResponse{Text: args.T("api.command_join.fail.app_error"), ResponseType: model.CommandResponseTypeEphemeral}
}
case model.ChannelTypePrivate:
if ok, _ := a.HasPermissionToChannel(rctx, args.UserId, channel.Id, model.PermissionReadChannel); !ok {
return &model.CommandResponse{Text: args.T("api.command_join.fail.app_error"), ResponseType: model.CommandResponseTypeEphemeral}
}
default:
return &model.CommandResponse{Text: args.T("api.command_join.fail.app_error"), ResponseType: model.CommandResponseTypeEphemeral}
}
if appErr := a.JoinChannel(rctx, channel, args.UserId); appErr != nil {
return &model.CommandResponse{Text: args.T("api.command_join.fail.app_error"), ResponseType: model.CommandResponseTypeEphemeral}
}
team, appErr := a.GetTeam(channel.TeamId)
if appErr != nil {
return &model.CommandResponse{Text: args.T("api.command_join.fail.app_error"), ResponseType: model.CommandResponseTypeEphemeral}
}
return &model.CommandResponse{GotoLocation: args.SiteURL + "/" + team.Name + "/channels/" + channel.Name}
}

View File

@@ -0,0 +1,142 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package slashcommands
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/i18n"
)
func TestJoinCommandNoChannel(t *testing.T) {
th := setup(t).initBasic(t)
if testing.Short() {
t.SkipNow()
}
cmd := &JoinProvider{}
resp := cmd.DoCommand(th.App, th.Context, &model.CommandArgs{
T: i18n.IdentityTfunc(),
UserId: th.BasicUser2.Id,
SiteURL: "http://test.url",
TeamId: th.BasicTeam.Id,
}, "asdsad")
assert.Equal(t, "api.command_join.list.app_error", resp.Text)
}
func TestJoinCommandForExistingChannel(t *testing.T) {
th := setup(t).initBasic(t)
if testing.Short() {
t.SkipNow()
}
channel2, _ := th.App.CreateChannel(th.Context, &model.Channel{
DisplayName: "AA",
Name: "aa" + model.NewId() + "a",
Type: model.ChannelTypeOpen,
TeamId: th.BasicTeam.Id,
CreatorId: th.BasicUser.Id,
}, false)
cmd := &JoinProvider{}
resp := cmd.DoCommand(th.App, th.Context, &model.CommandArgs{
T: i18n.IdentityTfunc(),
UserId: th.BasicUser2.Id,
SiteURL: "http://test.url",
TeamId: th.BasicTeam.Id,
}, channel2.Name)
assert.Equal(t, "", resp.Text)
assert.Equal(t, "http://test.url/"+th.BasicTeam.Name+"/channels/"+channel2.Name, resp.GotoLocation)
}
func TestJoinCommandWithTilde(t *testing.T) {
th := setup(t).initBasic(t)
if testing.Short() {
t.SkipNow()
}
channel2, _ := th.App.CreateChannel(th.Context, &model.Channel{
DisplayName: "AA",
Name: "aa" + model.NewId() + "a",
Type: model.ChannelTypeOpen,
TeamId: th.BasicTeam.Id,
CreatorId: th.BasicUser.Id,
}, false)
cmd := &JoinProvider{}
resp := cmd.DoCommand(th.App, th.Context, &model.CommandArgs{
T: i18n.IdentityTfunc(),
UserId: th.BasicUser2.Id,
SiteURL: "http://test.url",
TeamId: th.BasicTeam.Id,
}, "~"+channel2.Name)
assert.Equal(t, "", resp.Text)
assert.Equal(t, "http://test.url/"+th.BasicTeam.Name+"/channels/"+channel2.Name, resp.GotoLocation)
}
func TestJoinCommandPermissions(t *testing.T) {
th := setup(t).initBasic(t)
channel2, _ := th.App.CreateChannel(th.Context, &model.Channel{
DisplayName: "AA",
Name: "aa" + model.NewId() + "a",
Type: model.ChannelTypeOpen,
TeamId: th.BasicTeam.Id,
CreatorId: th.BasicUser.Id,
}, false)
cmd := &JoinProvider{}
user3 := th.createUser(t)
// Try a public channel *without* permission.
args := &model.CommandArgs{
T: i18n.IdentityTfunc(),
UserId: user3.Id,
SiteURL: "http://test.url",
TeamId: th.BasicTeam.Id,
}
actual := cmd.DoCommand(th.App, th.Context, args, "~"+channel2.Name).Text
assert.Equal(t, "api.command_join.fail.app_error", actual)
// Try a public channel with permission.
args = &model.CommandArgs{
T: i18n.IdentityTfunc(),
UserId: th.BasicUser2.Id,
SiteURL: "http://test.url",
TeamId: th.BasicTeam.Id,
}
actual = cmd.DoCommand(th.App, th.Context, args, "~"+channel2.Name).Text
assert.Equal(t, "", actual)
// Try a private channel *without* permission.
channel3, _ := th.App.CreateChannel(th.Context, &model.Channel{
DisplayName: "BB",
Name: "aa" + model.NewId() + "a",
Type: model.ChannelTypePrivate,
TeamId: th.BasicTeam.Id,
CreatorId: th.BasicUser.Id,
}, false)
args = &model.CommandArgs{
T: i18n.IdentityTfunc(),
UserId: th.BasicUser2.Id,
SiteURL: "http://test.url",
TeamId: th.BasicTeam.Id,
}
actual = cmd.DoCommand(th.App, th.Context, args, "~"+channel3.Name).Text
assert.Equal(t, "api.command_join.fail.app_error", actual)
}

View File

@@ -0,0 +1,80 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package slashcommands
import (
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/i18n"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/app"
)
type LeaveProvider struct {
}
const (
CmdLeave = "leave"
)
func init() {
app.RegisterCommandProvider(&LeaveProvider{})
}
func (*LeaveProvider) GetTrigger() string {
return CmdLeave
}
func (*LeaveProvider) GetCommand(a *app.App, T i18n.TranslateFunc) *model.Command {
return &model.Command{
Trigger: CmdLeave,
AutoComplete: true,
AutoCompleteDesc: T("api.command_leave.desc"),
DisplayName: T("api.command_leave.name"),
}
}
func (*LeaveProvider) DoCommand(a *app.App, rctx request.CTX, args *model.CommandArgs, message string) *model.CommandResponse {
var channel *model.Channel
var noChannelErr *model.AppError
if channel, noChannelErr = a.GetChannel(rctx, args.ChannelId); noChannelErr != nil {
return &model.CommandResponse{Text: args.T("api.command_leave.fail.app_error"), ResponseType: model.CommandResponseTypeEphemeral}
}
team, err := a.GetTeam(args.TeamId)
if err != nil {
return &model.CommandResponse{Text: args.T("api.command_leave.fail.app_error"), ResponseType: model.CommandResponseTypeEphemeral}
}
err = a.LeaveChannel(rctx, args.ChannelId, args.UserId)
if err != nil {
if channel.Name == model.DefaultChannelName {
return &model.CommandResponse{Text: args.T("api.channel.leave.default.app_error", map[string]any{"Channel": model.DefaultChannelName}), ResponseType: model.CommandResponseTypeEphemeral}
}
return &model.CommandResponse{Text: args.T("api.command_leave.fail.app_error"), ResponseType: model.CommandResponseTypeEphemeral}
}
member, err := a.GetTeamMember(rctx, team.Id, args.UserId)
if err != nil || member.DeleteAt != 0 {
return &model.CommandResponse{GotoLocation: args.SiteURL + "/"}
}
user, err := a.GetUser(rctx, args.UserId)
if err != nil {
return &model.CommandResponse{Text: args.T("api.command_leave.fail.app_error"), ResponseType: model.CommandResponseTypeEphemeral}
}
if user.IsGuest() {
members, err := a.GetChannelMembersForUser(rctx, team.Id, args.UserId)
if err != nil || len(members) == 0 {
return &model.CommandResponse{Text: args.T("api.command_leave.fail.app_error"), ResponseType: model.CommandResponseTypeEphemeral}
}
channel, err := a.GetChannel(rctx, members[0].ChannelId)
if err != nil {
return &model.CommandResponse{Text: args.T("api.command_leave.fail.app_error"), ResponseType: model.CommandResponseTypeEphemeral}
}
return &model.CommandResponse{GotoLocation: args.SiteURL + "/" + team.Name + "/channels/" + channel.Name}
}
return &model.CommandResponse{GotoLocation: args.SiteURL + "/" + team.Name + "/channels/" + model.DefaultChannelName}
}

View File

@@ -0,0 +1,152 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package slashcommands
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
)
func TestLeaveProviderDoCommand(t *testing.T) {
th := setup(t).initBasic(t)
lp := LeaveProvider{}
publicChannel, _ := th.App.CreateChannel(th.Context, &model.Channel{
DisplayName: "AA",
Name: "aa" + model.NewId() + "a",
Type: model.ChannelTypeOpen,
TeamId: th.BasicTeam.Id,
CreatorId: th.BasicUser.Id,
}, false)
privateChannel, _ := th.App.CreateChannel(th.Context, &model.Channel{
DisplayName: "BB",
Name: "aa" + model.NewId() + "a",
Type: model.ChannelTypeOpen,
TeamId: th.BasicTeam.Id,
CreatorId: th.BasicUser.Id,
}, false)
defaultChannel, err := th.App.GetChannelByName(th.Context, model.DefaultChannelName, th.BasicTeam.Id, false)
require.Nil(t, err)
guest := th.createGuest(t)
_, _, appErr := th.App.AddUserToTeam(th.Context, th.BasicTeam.Id, th.BasicUser.Id, th.BasicUser.Id)
require.Nil(t, appErr)
_, appErr = th.App.AddUserToChannel(th.Context, th.BasicUser, publicChannel, false)
require.Nil(t, appErr)
_, appErr = th.App.AddUserToChannel(th.Context, th.BasicUser, privateChannel, false)
require.Nil(t, appErr)
_, _, appErr = th.App.AddUserToTeam(th.Context, th.BasicTeam.Id, guest.Id, guest.Id)
require.Nil(t, appErr)
_, appErr = th.App.AddUserToChannel(th.Context, guest, publicChannel, false)
require.Nil(t, appErr)
_, appErr = th.App.AddUserToChannel(th.Context, guest, defaultChannel, false)
require.Nil(t, appErr)
t.Run("Should error when no Channel ID in args", func(t *testing.T) {
args := &model.CommandArgs{
UserId: th.BasicUser.Id,
T: func(s string, args ...any) string { return s },
}
actual := lp.DoCommand(th.App, th.Context, args, "")
assert.Equal(t, "api.command_leave.fail.app_error", actual.Text)
assert.Equal(t, model.CommandResponseTypeEphemeral, actual.ResponseType)
})
t.Run("Should error when no Team ID in args", func(t *testing.T) {
args := &model.CommandArgs{
UserId: th.BasicUser.Id,
ChannelId: publicChannel.Id,
T: func(s string, args ...any) string { return s },
}
actual := lp.DoCommand(th.App, th.Context, args, "")
assert.Equal(t, "api.command_leave.fail.app_error", actual.Text)
assert.Equal(t, model.CommandResponseTypeEphemeral, actual.ResponseType)
})
t.Run("Leave a public channel", func(t *testing.T) {
args := &model.CommandArgs{
UserId: th.BasicUser.Id,
ChannelId: publicChannel.Id,
T: func(s string, args ...any) string { return s },
TeamId: th.BasicTeam.Id,
SiteURL: "http://localhost:8065",
}
actual := lp.DoCommand(th.App, th.Context, args, "")
assert.Equal(t, "", actual.Text)
assert.Equal(t, args.SiteURL+"/"+th.BasicTeam.Name+"/channels/"+model.DefaultChannelName, actual.GotoLocation)
assert.Equal(t, "", actual.ResponseType)
_, err = th.App.GetChannelMember(th.Context, publicChannel.Id, th.BasicUser.Id)
assert.NotNil(t, err)
assert.NotNil(t, err.Id, "app.channel.get_member.missing.app_error")
})
t.Run("Leave a private channel", func(t *testing.T) {
args := &model.CommandArgs{
UserId: th.BasicUser.Id,
ChannelId: privateChannel.Id,
T: func(s string, args ...any) string { return s },
TeamId: th.BasicTeam.Id,
SiteURL: "http://localhost:8065",
}
actual := lp.DoCommand(th.App, th.Context, args, "")
assert.Equal(t, "", actual.Text)
})
t.Run("Should not leave a default channel", func(t *testing.T) {
args := &model.CommandArgs{
UserId: th.BasicUser.Id,
ChannelId: defaultChannel.Id,
T: func(s string, args ...any) string { return s },
TeamId: th.BasicTeam.Id,
SiteURL: "http://localhost:8065",
}
actual := lp.DoCommand(th.App, th.Context, args, "")
assert.Equal(t, "api.channel.leave.default.app_error", actual.Text)
})
t.Run("Should allow to leave a default channel if user is guest", func(t *testing.T) {
args := &model.CommandArgs{
UserId: guest.Id,
ChannelId: defaultChannel.Id,
T: func(s string, args ...any) string { return s },
TeamId: th.BasicTeam.Id,
SiteURL: "http://localhost:8065",
}
actual := lp.DoCommand(th.App, th.Context, args, "")
assert.Equal(t, "", actual.Text)
assert.Equal(t, args.SiteURL+"/"+th.BasicTeam.Name+"/channels/"+publicChannel.Name, actual.GotoLocation)
assert.Equal(t, "", actual.ResponseType)
_, err = th.App.GetChannelMember(th.Context, defaultChannel.Id, guest.Id)
assert.NotNil(t, err)
assert.NotNil(t, err.Id, "app.channel.get_member.missing.app_error")
})
t.Run("Should redirect to the team if is the last channel", func(t *testing.T) {
args := &model.CommandArgs{
UserId: guest.Id,
ChannelId: publicChannel.Id,
T: func(s string, args ...any) string { return s },
TeamId: th.BasicTeam.Id,
SiteURL: "http://localhost:8065",
}
actual := lp.DoCommand(th.App, th.Context, args, "")
assert.Equal(t, "", actual.Text)
assert.Equal(t, args.SiteURL+"/", actual.GotoLocation)
assert.Equal(t, "", actual.ResponseType)
_, err = th.App.GetChannelMember(th.Context, publicChannel.Id, guest.Id)
assert.NotNil(t, err)
assert.NotNil(t, err.Id, "app.channel.get_member.missing.app_error")
})
}

View File

@@ -0,0 +1,755 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package slashcommands
import (
"context"
"encoding/json"
"io"
"net/http"
"path"
"regexp"
"slices"
"strconv"
"strings"
"github.com/pkg/errors"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/i18n"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/app"
"github.com/mattermost/mattermost/server/v8/channels/utils"
)
var usage = `Mattermost testing commands to help configure the system
COMMANDS:
Setup - Creates a testing environment in current team.
/test setup [teams] [fuzz] <Num Channels> <Num Users> <NumPosts>
Example:
/test setup teams fuzz 10 20 50
Users - Add a specified number of random users with fuzz text to current team, at the specified Unix timestamp in milliseconds.
/test users [fuzz] [range=min[,max]] [time=user_join_timestamp]
Default: range=2,5 time=
Examples:
/test users fuzz range=3,8 time=1565076128000
/test users range=1
Channels - Add a specified number of random public (o) or private (p) channels with fuzz text to current team, at the specified Unix timestamp in milliseconds.
/test channels [fuzz] [range=min[,max]] [type=(o|p)] [time=channel_create_timestamp]
Default: range=2,5 type=o time=
Examples:
/test channels fuzz range=5,10 type=p time=1565076128000
/test channels range=1
DMs - Add a specified number of random DM messages between the current user and a specified user, at the specified Unix timestamp in milliseconds. If a timestamp is provided, posts are created one millisecond apart. Note: You may need to clear your browser cache in order to see these posts in the UI.
/test dms u=@username [range=min[,max]] [time=dm_create_timestamp]
Default: range=2,5 time=
Examples:
/test dms u=@user range=5,10 time=1565076128000
/test dms u=@user range=2
ThreadedPost - Create a threaded post with a specified number of replies at the specified Unix timestamp in milliseconds. If a timestamp is provided, posts are created one millisecond apart. Note: You may need to clear your browser cache in order to see these posts in the UI.
/test threaded_post [range=min[,max]] [time=post_timestamp]
Default: range=1000 time=
Examples:
/test threaded_post
/test threaded_post range=100,200 time=1565076128000
Posts - Add some random posts with fuzz text to current channel, at the specified Unix timestamp in milliseconds. If a timestamp is provided, posts are created one millisecond apart. Note: You may need to clear your browser cache in order to see these posts in the UI.
/test posts [fuzz] [range=min[,max]] [images=max_images] [time=post_timestamp]
Default: range=2,5 images=0 time=
Example:
/test posts fuzz range=5,10 images=3 time=1565076128000
/test posts range=2
Post - Add post to a channel as another user.
/test post u=@username p=passwd c=~channelname t=teamname "message"
Example:
/test post u=@user-1 p=user-1 c=~town-square t=ad-1 "message"
Url - Add a post containing the text from a given url to current channel.
/test url
Example:
/test http://www.example.com/sample_file.md
Json - Add a post using the JSON file as payload to the current channel.
/test json url
Example:
/test json http://www.example.com/sample_body.json
`
const (
CmdTest = "test"
)
var (
userRE = regexp.MustCompile(`u=@?([^\s]+)`)
passwdRE = regexp.MustCompile(`p=([^\s]+)`)
teamRE = regexp.MustCompile(`t=([^\s]+)`)
channelRE = regexp.MustCompile(`c=~([^\s]+)`)
messageRE = regexp.MustCompile(`"(.*)"`)
fuzzRE = regexp.MustCompile(`fuzz`)
rangeRE = regexp.MustCompile(`range=([^\s]+)`)
timeRE = regexp.MustCompile(`time=([^\s]+)`)
imagesRE = regexp.MustCompile(`images=([^\s]+)`)
typeRE = regexp.MustCompile(`type=([^\s])+`)
)
type LoadTestProvider struct {
}
func init() {
app.RegisterCommandProvider(&LoadTestProvider{})
}
func (*LoadTestProvider) GetTrigger() string {
return CmdTest
}
func (*LoadTestProvider) GetCommand(a *app.App, T i18n.TranslateFunc) *model.Command {
if !*a.Config().ServiceSettings.EnableTesting {
return nil
}
return &model.Command{
Trigger: CmdTest,
AutoComplete: false,
AutoCompleteDesc: "Debug Load Testing",
AutoCompleteHint: "help",
DisplayName: "test",
}
}
func (lt *LoadTestProvider) DoCommand(a *app.App, rctx request.CTX, args *model.CommandArgs, message string) *model.CommandResponse {
commandResponse, err := lt.doCommand(a, rctx, args, message)
if err != nil {
rctx.Logger().Error("failed command /"+CmdTest, mlog.Err(err))
}
return commandResponse
}
func (lt *LoadTestProvider) doCommand(a *app.App, rctx request.CTX, args *model.CommandArgs, message string) (*model.CommandResponse, error) {
//This command is only available when EnableTesting is true
if !*a.Config().ServiceSettings.EnableTesting {
return &model.CommandResponse{}, nil
}
if strings.HasPrefix(message, "setup") {
return lt.SetupCommand(a, rctx, args, message)
}
if strings.HasPrefix(message, "users") {
return lt.UsersCommand(a, rctx, args, message)
}
if strings.HasPrefix(message, "activate_user") {
return lt.ActivateUserCommand(a, rctx, args, message)
}
if strings.HasPrefix(message, "deactivate_user") {
return lt.DeActivateUserCommand(a, rctx, args, message)
}
if strings.HasPrefix(message, "channels") {
return lt.ChannelsCommand(a, rctx, args, message)
}
if strings.HasPrefix(message, "dms") {
return lt.DMsCommand(a, rctx, args, message)
}
if strings.HasPrefix(message, "posts") {
return lt.PostsCommand(a, rctx, args, message)
}
if strings.HasPrefix(message, "post") {
return lt.PostCommand(a, rctx, args, message)
}
if strings.HasPrefix(message, "threaded_post") {
return lt.ThreadedPostCommand(a, rctx, args, message)
}
if strings.HasPrefix(message, "url") {
return lt.URLCommand(a, rctx, args, message)
}
if strings.HasPrefix(message, "json") {
return lt.JSONCommand(a, rctx, args, message)
}
return lt.HelpCommand(args, message), nil
}
func (*LoadTestProvider) HelpCommand(args *model.CommandArgs, message string) *model.CommandResponse {
return &model.CommandResponse{Text: usage, ResponseType: model.CommandResponseTypeEphemeral}
}
func (*LoadTestProvider) SetupCommand(a *app.App, rctx request.CTX, args *model.CommandArgs, message string) (*model.CommandResponse, error) {
tokens := strings.Fields(strings.TrimPrefix(message, "setup"))
doTeams := slices.Contains(tokens, "teams")
doFuzz := slices.Contains(tokens, "fuzz")
numArgs := 0
if doTeams {
numArgs++
}
if doFuzz {
numArgs++
}
var numTeams int
var numChannels int
var numUsers int
var numPosts int
// Defaults
numTeams = 10
numChannels = 10
numUsers = 10
numPosts = 10
if doTeams {
if (len(tokens) - numArgs) >= 4 {
numTeams, _ = strconv.Atoi(tokens[numArgs+0])
numChannels, _ = strconv.Atoi(tokens[numArgs+1])
numUsers, _ = strconv.Atoi(tokens[numArgs+2])
numPosts, _ = strconv.Atoi(tokens[numArgs+3])
}
} else {
if (len(tokens) - numArgs) >= 3 {
numChannels, _ = strconv.Atoi(tokens[numArgs+0])
numUsers, _ = strconv.Atoi(tokens[numArgs+1])
numPosts, _ = strconv.Atoi(tokens[numArgs+2])
}
}
client := model.NewAPIv4Client(args.SiteURL)
if doTeams {
if err := CreateBasicUser(rctx, a, client); err != nil {
return &model.CommandResponse{Text: "Failed to create testing environment", ResponseType: model.CommandResponseTypeEphemeral}, err
}
_, _, err := client.Login(context.Background(), BTestUserEmail, BTestUserPassword)
if err != nil {
return &model.CommandResponse{Text: "Failed to create testing environment", ResponseType: model.CommandResponseTypeEphemeral}, err
}
environment, err := CreateTestEnvironmentWithTeams(
a,
rctx,
client,
utils.Range{Begin: numTeams, End: numTeams},
utils.Range{Begin: numChannels, End: numChannels},
utils.Range{Begin: numUsers, End: numUsers},
utils.Range{Begin: numPosts, End: numPosts},
doFuzz)
if err != nil {
return &model.CommandResponse{Text: "Failed to create testing environment", ResponseType: model.CommandResponseTypeEphemeral}, err
}
rctx.Logger().Info("Testing environment created")
for i := range environment.Teams {
rctx.Logger().Info("Team Created: " + environment.Teams[i].Name)
rctx.Logger().Info("\t User to login: " + environment.Environments[i].Users[0].Email + ", " + UserPassword)
}
} else {
team, err := a.Srv().Store().Team().Get(args.TeamId)
if err != nil {
return &model.CommandResponse{Text: "Failed to create testing environment", ResponseType: model.CommandResponseTypeEphemeral}, err
}
_, err = CreateTestEnvironmentInTeam(
a,
rctx,
client,
team,
utils.Range{Begin: numChannels, End: numChannels},
utils.Range{Begin: numUsers, End: numUsers},
utils.Range{Begin: numPosts, End: numPosts},
doFuzz)
if err != nil {
return &model.CommandResponse{Text: "Failed to create testing environment", ResponseType: model.CommandResponseTypeEphemeral}, err
}
}
return &model.CommandResponse{Text: "Created environment", ResponseType: model.CommandResponseTypeEphemeral}, nil
}
func (*LoadTestProvider) ActivateUserCommand(a *app.App, rctx request.CTX, args *model.CommandArgs, message string) (*model.CommandResponse, error) {
userID := strings.TrimSpace(strings.TrimPrefix(message, "activate_user"))
if err := a.UpdateUserActive(rctx, userID, true); err != nil {
return &model.CommandResponse{Text: "Failed to activate user", ResponseType: model.CommandResponseTypeEphemeral}, err
}
return &model.CommandResponse{Text: "Activated user", ResponseType: model.CommandResponseTypeEphemeral}, nil
}
func (*LoadTestProvider) DeActivateUserCommand(a *app.App, rctx request.CTX, args *model.CommandArgs, message string) (*model.CommandResponse, error) {
userID := strings.TrimSpace(strings.TrimPrefix(message, "deactivate_user"))
if err := a.UpdateUserActive(rctx, userID, false); err != nil {
return &model.CommandResponse{Text: "Failed to deactivate user", ResponseType: model.CommandResponseTypeEphemeral}, err
}
return &model.CommandResponse{Text: "DeActivated user", ResponseType: model.CommandResponseTypeEphemeral}, nil
}
func (*LoadTestProvider) UsersCommand(a *app.App, rctx request.CTX, args *model.CommandArgs, message string) (*model.CommandResponse, error) {
cmd := strings.TrimSpace(strings.TrimPrefix(message, "users"))
doFuzz := false
if fuzzRE.MatchString(cmd) {
doFuzz = true
}
var err error
rng := utils.Range{Begin: 2, End: 5}
rangeParam := getMatch(rangeRE, cmd)
if rangeParam != "" {
rng, err = parseRange(rangeParam)
if err != nil {
return &model.CommandResponse{Text: "Failed to add users: " + err.Error(), ResponseType: model.CommandResponseTypeEphemeral}, err
}
}
team, err := a.Srv().Store().Team().Get(args.TeamId)
if err != nil {
return &model.CommandResponse{Text: "Failed to add users", ResponseType: model.CommandResponseTypeEphemeral}, err
}
time := int64(0)
timeParam := getMatch(timeRE, cmd)
if timeParam != "" {
time, err = strconv.ParseInt(timeParam, 10, 64)
if err != nil || time < 0 {
return &model.CommandResponse{Text: "Failed to add users: Invalid time parameter", ResponseType: model.CommandResponseTypeEphemeral}, errors.New("Invalid time parameter")
}
}
client := model.NewAPIv4Client(args.SiteURL)
userCreator := NewAutoUserCreator(a, client, team)
userCreator.Fuzzy = doFuzz
userCreator.JoinTime = time
if _, err := userCreator.CreateTestUsers(rctx, rng); err != nil {
return &model.CommandResponse{Text: "Failed to add users: " + err.Error(), ResponseType: model.CommandResponseTypeEphemeral}, err
}
return &model.CommandResponse{Text: "Added users", ResponseType: model.CommandResponseTypeEphemeral}, nil
}
func (*LoadTestProvider) ChannelsCommand(a *app.App, rctx request.CTX, args *model.CommandArgs, message string) (*model.CommandResponse, error) {
cmd := strings.TrimSpace(strings.TrimPrefix(message, "channels"))
doFuzz := false
if fuzzRE.MatchString(cmd) {
doFuzz = true
}
var err error
rng := utils.Range{Begin: 2, End: 5}
rangeParam := getMatch(rangeRE, cmd)
if rangeParam != "" {
rng, err = parseRange(rangeParam)
if err != nil {
return &model.CommandResponse{Text: "Failed to add channels: " + err.Error(), ResponseType: model.CommandResponseTypeEphemeral}, err
}
}
team, err := a.Srv().Store().Team().Get(args.TeamId)
if err != nil {
return &model.CommandResponse{Text: "Failed to add channels", ResponseType: model.CommandResponseTypeEphemeral}, err
}
typ := model.ChannelTypeOpen
typeParam := getMatch(typeRE, cmd)
if typeParam != "" {
switch strings.ToUpper(typeParam) {
case "O":
case "P":
typ = model.ChannelTypePrivate
default:
return &model.CommandResponse{Text: "Failed to add channels: Invalid type parameter", ResponseType: model.CommandResponseTypeEphemeral}, errors.New("Invalid type parameter")
}
}
time := int64(0)
timeParam := getMatch(timeRE, cmd)
if timeParam != "" {
time, err = strconv.ParseInt(timeParam, 10, 64)
if err != nil || time < 0 {
return &model.CommandResponse{Text: "Failed to add channels: Invalid time parameter", ResponseType: model.CommandResponseTypeEphemeral}, errors.New("Invalid time parameter")
}
}
channelCreator := NewAutoChannelCreator(a, team, args.UserId)
channelCreator.Fuzzy = doFuzz
channelCreator.CreateTime = time
channelCreator.ChannelType = typ
if _, err := channelCreator.CreateTestChannels(rctx, rng); err != nil {
return &model.CommandResponse{Text: "Failed to create test channels: " + err.Error(), ResponseType: model.CommandResponseTypeEphemeral}, err
}
return &model.CommandResponse{Text: "Added channels", ResponseType: model.CommandResponseTypeEphemeral}, nil
}
func (*LoadTestProvider) DMsCommand(a *app.App, rctx request.CTX, args *model.CommandArgs, message string) (*model.CommandResponse, error) {
cmd := strings.TrimSpace(strings.TrimPrefix(message, "dms"))
var err error
username := getMatch(userRE, message)
user, appErr := a.GetUserByUsername(username)
if appErr != nil {
return &model.CommandResponse{Text: "Failed to add DMS: Invalid username", ResponseType: model.CommandResponseTypeEphemeral}, appErr
}
rng := utils.Range{Begin: 2, End: 5}
rangeParam := getMatch(rangeRE, cmd)
if rangeParam != "" {
rng, err = parseRange(rangeParam)
if err != nil {
return &model.CommandResponse{Text: "Failed to add DMs: " + err.Error(), ResponseType: model.CommandResponseTypeEphemeral}, err
}
}
time := int64(0)
timeParam := getMatch(timeRE, cmd)
if timeParam != "" {
time, err = strconv.ParseInt(timeParam, 10, 64)
if err != nil || time < 0 {
return &model.CommandResponse{Text: "Failed to add DMs: Invalid time parameter", ResponseType: model.CommandResponseTypeEphemeral}, errors.New("Invalid time parameter")
}
}
channel, err := a.GetOrCreateDirectChannel(rctx, args.UserId, user.Id)
postCreator := NewAutoPostCreator(a, channel.Id, args.UserId)
postCreator.CreateTime = time
postCreator.UsersToPostFrom = []string{user.Id}
numPosts := utils.RandIntFromRange(rng)
for range numPosts {
if _, err := postCreator.CreateRandomPost(rctx); err != nil {
return &model.CommandResponse{Text: "Failed to create test DMs: " + err.Error(), ResponseType: model.CommandResponseTypeEphemeral}, err
}
}
return &model.CommandResponse{Text: "Added DMs", ResponseType: model.CommandResponseTypeEphemeral}, nil
}
func (*LoadTestProvider) ThreadedPostCommand(a *app.App, rctx request.CTX, args *model.CommandArgs, message string) (*model.CommandResponse, error) {
cmd := strings.TrimSpace(strings.TrimPrefix(message, "threaded_post"))
var err error
rng := utils.Range{Begin: 1000, End: 1000}
rangeParam := getMatch(rangeRE, cmd)
if rangeParam != "" {
rng, err = parseRange(rangeParam)
if err != nil {
return &model.CommandResponse{Text: "Failed to create post: " + err.Error(), ResponseType: model.CommandResponseTypeEphemeral}, err
}
}
time := int64(0)
timeParam := getMatch(timeRE, cmd)
if timeParam != "" {
time, err = strconv.ParseInt(timeParam, 10, 64)
if err != nil || time < 0 {
return &model.CommandResponse{Text: "Failed to create post: Invalid time parameter", ResponseType: model.CommandResponseTypeEphemeral}, errors.New("Invalid time parameter")
}
}
var usernames []string
var profileUsers []*model.User
options := &model.UserGetOptions{InTeamId: args.TeamId, Page: 0, PerPage: 1000}
if profileUsers, err = a.Srv().Store().User().GetProfiles(options); err == nil {
usernames = make([]string, len(profileUsers))
i := 0
for _, userprof := range profileUsers {
usernames[i] = userprof.Username
i++
}
}
testPoster := NewAutoPostCreator(a, args.ChannelId, args.UserId)
testPoster.Fuzzy = true
testPoster.Users = usernames
testPoster.CreateTime = time
rpost, err2 := testPoster.CreateRandomPost(rctx)
if err2 != nil {
return &model.CommandResponse{Text: "Failed to create a post", ResponseType: model.CommandResponseTypeEphemeral}, err2
}
numPosts := utils.RandIntFromRange(rng)
for range numPosts {
_, err = testPoster.CreateRandomPostNested(rctx, rpost.Id)
if err != nil {
return &model.CommandResponse{Text: "Failed to create nested post", ResponseType: model.CommandResponseTypeEphemeral}, err
}
}
return &model.CommandResponse{Text: "Added threaded post", ResponseType: model.CommandResponseTypeEphemeral}, nil
}
func (*LoadTestProvider) PostsCommand(a *app.App, rctx request.CTX, args *model.CommandArgs, message string) (*model.CommandResponse, error) {
cmd := strings.TrimSpace(strings.TrimPrefix(message, "posts"))
doFuzz := false
if fuzzRE.MatchString(cmd) {
doFuzz = true
}
var err error
rng := utils.Range{Begin: 2, End: 5}
rangeParam := getMatch(rangeRE, cmd)
if rangeParam != "" {
rng, err = parseRange(rangeParam)
if err != nil {
return &model.CommandResponse{Text: "Failed to add posts: " + err.Error(), ResponseType: model.CommandResponseTypeEphemeral}, err
}
}
maxImages := 0
imagesParam := getMatch(imagesRE, cmd)
if imagesParam != "" {
maxImages, err = strconv.Atoi(imagesParam)
if err != nil {
return &model.CommandResponse{Text: "Failed to add posts: Invalid images parameter", ResponseType: model.CommandResponseTypeEphemeral}, errors.New("Invalid images parameter")
}
}
time := int64(0)
timeParam := getMatch(timeRE, cmd)
if timeParam != "" {
time, err = strconv.ParseInt(timeParam, 10, 64)
if err != nil || time < 0 {
return &model.CommandResponse{Text: "Failed to add posts: Invalid time parameter", ResponseType: model.CommandResponseTypeEphemeral}, errors.New("Invalid time parameter")
}
}
var usernames []string
options := &model.UserGetOptions{InTeamId: args.TeamId, Page: 0, PerPage: 1000}
if profileUsers, err := a.Srv().Store().User().GetProfiles(options); err == nil {
usernames = make([]string, len(profileUsers))
i := 0
for _, userprof := range profileUsers {
usernames[i] = userprof.Username
i++
}
}
testPoster := NewAutoPostCreator(a, args.ChannelId, args.UserId)
testPoster.Fuzzy = doFuzz
testPoster.Users = usernames
testPoster.CreateTime = time
numImages := utils.RandIntFromRange(utils.Range{Begin: 0, End: maxImages})
numPosts := utils.RandIntFromRange(rng)
for i := range numPosts {
testPoster.HasImage = (i < numImages)
_, err := testPoster.CreateRandomPost(rctx)
if err != nil {
return &model.CommandResponse{Text: "Failed to add posts", ResponseType: model.CommandResponseTypeEphemeral}, err
}
}
return &model.CommandResponse{Text: "Added posts", ResponseType: model.CommandResponseTypeEphemeral}, nil
}
func getMatch(re *regexp.Regexp, text string) string {
if match := re.FindStringSubmatch(text); match != nil {
return match[1]
}
return ""
}
func (*LoadTestProvider) PostCommand(a *app.App, rctx request.CTX, args *model.CommandArgs, message string) (*model.CommandResponse, error) {
textMessage := getMatch(messageRE, message)
if textMessage == "" {
return &model.CommandResponse{Text: "No message to post", ResponseType: model.CommandResponseTypeEphemeral}, nil
}
teamName := getMatch(teamRE, message)
team, err := a.GetTeamByName(teamName)
if err != nil {
return &model.CommandResponse{Text: "Failed to get a team", ResponseType: model.CommandResponseTypeEphemeral}, err
}
channelName := getMatch(channelRE, message)
channel, err := a.GetChannelByName(rctx, channelName, team.Id, true)
if err != nil {
return &model.CommandResponse{Text: "Failed to get a channel", ResponseType: model.CommandResponseTypeEphemeral}, err
}
passwd := getMatch(passwdRE, message)
username := getMatch(userRE, message)
user, err := a.GetUserByUsername(username)
if err != nil {
return &model.CommandResponse{Text: "Failed to get a user", ResponseType: model.CommandResponseTypeEphemeral}, err
}
client := model.NewAPIv4Client(args.SiteURL)
_, _, nErr := client.LoginById(context.Background(), user.Id, passwd)
if nErr != nil {
return &model.CommandResponse{Text: "Failed to login a user", ResponseType: model.CommandResponseTypeEphemeral}, nErr
}
post := &model.Post{
ChannelId: channel.Id,
Message: textMessage,
}
_, _, nErr = client.CreatePost(context.Background(), post)
if nErr != nil {
return &model.CommandResponse{Text: "Failed to create a post", ResponseType: model.CommandResponseTypeEphemeral}, nErr
}
return &model.CommandResponse{Text: "Added a post to " + channel.DisplayName, ResponseType: model.CommandResponseTypeEphemeral}, nil
}
func (*LoadTestProvider) URLCommand(a *app.App, rctx request.CTX, args *model.CommandArgs, message string) (*model.CommandResponse, error) {
url := strings.TrimSpace(strings.TrimPrefix(message, "url"))
if url == "" {
return &model.CommandResponse{Text: "Command must contain a url", ResponseType: model.CommandResponseTypeEphemeral}, nil
}
// provide a shortcut to easily access tests stored in doc/developer/tests
if !strings.HasPrefix(url, "http") {
url = "https://raw.githubusercontent.com/mattermost/mattermost-server/master/server/tests/" + url
if path.Ext(url) == "" {
url += ".md"
}
}
r, err := http.Get(url)
if err != nil {
return &model.CommandResponse{Text: "Unable to get file", ResponseType: model.CommandResponseTypeEphemeral}, err
}
defer func() {
_, err := io.Copy(io.Discard, r.Body)
if err != nil {
rctx.Logger().Warn("Error discarding request body", mlog.Err(err))
}
r.Body.Close()
}()
if r.StatusCode > 400 {
return &model.CommandResponse{Text: "Unable to get file", ResponseType: model.CommandResponseTypeEphemeral}, errors.Errorf("unexpected status code %d", r.StatusCode)
}
bytes := make([]byte, 4000)
// break contents into 4000 byte posts
for {
length, err := r.Body.Read(bytes)
if err != nil && err != io.EOF {
return &model.CommandResponse{Text: "Encountered error reading file", ResponseType: model.CommandResponseTypeEphemeral}, err
}
if length == 0 {
break
}
post := &model.Post{}
post.Message = string(bytes[:length])
post.ChannelId = args.ChannelId
post.UserId = args.UserId
if _, _, err := a.CreatePostMissingChannel(rctx, post, false, true); err != nil {
return &model.CommandResponse{Text: "Unable to create post", ResponseType: model.CommandResponseTypeEphemeral}, err
}
}
return &model.CommandResponse{Text: "Loaded data", ResponseType: model.CommandResponseTypeEphemeral}, nil
}
func (*LoadTestProvider) JSONCommand(a *app.App, rctx request.CTX, args *model.CommandArgs, message string) (*model.CommandResponse, error) {
url := strings.TrimSpace(strings.TrimPrefix(message, "json"))
if url == "" {
return &model.CommandResponse{Text: "Command must contain a url", ResponseType: model.CommandResponseTypeEphemeral}, nil
}
// provide a shortcut to easily access tests stored in doc/developer/tests
if !strings.HasPrefix(url, "http") {
url = "https://raw.githubusercontent.com/mattermost/mattermost-server/master/server/tests/" + url
if path.Ext(url) == "" {
url += ".json"
}
}
r, err := http.Get(url)
if err != nil {
return &model.CommandResponse{Text: "Unable to get file", ResponseType: model.CommandResponseTypeEphemeral}, err
}
if r.StatusCode > 400 {
return &model.CommandResponse{Text: "Unable to get file", ResponseType: model.CommandResponseTypeEphemeral}, errors.Errorf("unexpected status code %d", r.StatusCode)
}
defer func() {
_, err := io.Copy(io.Discard, r.Body)
if err != nil {
rctx.Logger().Warn("Error discarding request body", mlog.Err(err))
}
r.Body.Close()
}()
var post model.Post
if jsonErr := json.NewDecoder(r.Body).Decode(&post); jsonErr != nil {
return &model.CommandResponse{Text: "Unable to decode post", ResponseType: model.CommandResponseTypeEphemeral}, errors.Wrapf(jsonErr, "could not decode post from json")
}
post.ChannelId = args.ChannelId
post.UserId = args.UserId
if post.Message == "" {
post.Message = message
}
if _, _, err := a.CreatePostMissingChannel(rctx, &post, false, true); err != nil {
return &model.CommandResponse{Text: "Unable to create post", ResponseType: model.CommandResponseTypeEphemeral}, err
}
return &model.CommandResponse{Text: "Loaded data", ResponseType: model.CommandResponseTypeEphemeral}, nil
}
func parseRange(rng string) (utils.Range, error) {
tokens := strings.Split(rng, ",")
var begin int
var end int
var err1 error
var err2 error
switch {
case len(tokens) == 1:
begin, err1 = strconv.Atoi(tokens[0])
if err1 != nil || begin < 0 {
return utils.Range{Begin: 0, End: 0}, errors.New("Invalid range parameter")
}
end = begin
case len(tokens) == 2:
begin, err1 = strconv.Atoi(tokens[0])
end, err2 = strconv.Atoi(tokens[1])
if err1 != nil || err2 != nil || begin < 0 || end < begin {
return utils.Range{Begin: 0, End: 0}, errors.New("Invalid range parameter")
}
default:
return utils.Range{Begin: 0, End: 0}, errors.New("Invalid range parameter")
}
return utils.Range{Begin: begin, End: end}, nil
}

View File

@@ -0,0 +1,41 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package slashcommands
import (
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/i18n"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/app"
)
type LogoutProvider struct {
}
const (
CmdLogout = "logout"
)
func init() {
app.RegisterCommandProvider(&LogoutProvider{})
}
func (*LogoutProvider) GetTrigger() string {
return CmdLogout
}
func (*LogoutProvider) GetCommand(a *app.App, T i18n.TranslateFunc) *model.Command {
return &model.Command{
Trigger: CmdLogout,
AutoComplete: true,
AutoCompleteDesc: T("api.command_logout.desc"),
AutoCompleteHint: "",
DisplayName: T("api.command_logout.name"),
}
}
func (*LogoutProvider) DoCommand(a *app.App, _ request.CTX, args *model.CommandArgs, message string) *model.CommandResponse {
// Actual logout is handled client side.
return &model.CommandResponse{GotoLocation: "/login"}
}

View File

@@ -0,0 +1,49 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package slashcommands
import (
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/i18n"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/app"
)
type MarketplaceProvider struct {
}
const (
CmdMarketplace = "marketplace"
)
func init() {
app.RegisterCommandProvider(&MarketplaceProvider{})
}
func (h *MarketplaceProvider) GetTrigger() string {
return CmdMarketplace
}
func (h *MarketplaceProvider) GetCommand(a *app.App, T i18n.TranslateFunc) *model.Command {
enabled := false
pluginSettings := a.Config().PluginSettings
if *pluginSettings.Enable && *pluginSettings.EnableMarketplace {
enabled = true
}
return &model.Command{
Trigger: CmdMarketplace,
AutoComplete: enabled,
AutoCompleteDesc: T("api.command_marketplace.desc"),
DisplayName: T("api.command_marketplace.name"),
}
}
func (h *MarketplaceProvider) DoCommand(a *app.App, rctx request.CTX, args *model.CommandArgs, message string) *model.CommandResponse {
// This command is handled client-side and shouldn't hit the server.
return &model.CommandResponse{
Text: args.T("api.command_marketplace.unsupported.app_error"),
ResponseType: model.CommandResponseTypeEphemeral,
}
}

View File

@@ -0,0 +1,60 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package slashcommands
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
)
func TestMarketplaceProviderGetCommand(t *testing.T) {
th := setup(t).initBasic(t)
mp := MarketplaceProvider{}
testCases := []struct {
TestName string
PluginEnabled bool
MarketplaceEnabled bool
MustAutocomplete bool
}{
{
"All true",
true, true,
true,
},
{
"Plugin false",
false, true,
false,
},
{
"Marketplace false",
true, false,
false,
},
{
"All false",
false, false,
false,
},
}
for _, tc := range testCases {
t.Run(tc.TestName, func(t *testing.T) {
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.PluginSettings.Enable = tc.PluginEnabled
*cfg.PluginSettings.EnableMarketplace = tc.MarketplaceEnabled
})
cmd := mp.GetCommand(th.App, th.Context.T)
require.Equal(t, tc.MustAutocomplete, cmd.AutoComplete)
})
}
}

View File

@@ -0,0 +1,44 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package slashcommands
import (
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/i18n"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/app"
)
type MeProvider struct {
}
const (
CmdMe = "me"
)
func init() {
app.RegisterCommandProvider(&MeProvider{})
}
func (*MeProvider) GetTrigger() string {
return CmdMe
}
func (*MeProvider) GetCommand(a *app.App, T i18n.TranslateFunc) *model.Command {
return &model.Command{
Trigger: CmdMe,
AutoComplete: true,
AutoCompleteDesc: T("api.command_me.desc"),
AutoCompleteHint: T("api.command_me.hint"),
DisplayName: T("api.command_me.name"),
}
}
func (*MeProvider) DoCommand(a *app.App, rctx request.CTX, args *model.CommandArgs, message string) *model.CommandResponse {
return &model.CommandResponse{
ResponseType: model.CommandResponseTypeInChannel,
Type: model.PostTypeMe,
Text: "*" + message + "*",
}
}

View File

@@ -0,0 +1,26 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package slashcommands
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/mattermost/mattermost/server/public/model"
)
func TestMeProviderDoCommand(t *testing.T) {
th := setup(t)
mp := MeProvider{}
msg := "hello"
resp := mp.DoCommand(th.App, th.Context, &model.CommandArgs{}, msg)
assert.Equal(t, model.CommandResponseTypeInChannel, resp.ResponseType)
assert.Equal(t, model.PostTypeMe, resp.Type)
assert.Equal(t, "*"+msg+"*", resp.Text)
}

View File

@@ -0,0 +1,246 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package slashcommands
import (
"net/http"
"strings"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/i18n"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/app"
)
type MobileLogsProvider struct {
}
const (
CmdMobileLogs = "mobile-logs"
)
func init() {
app.RegisterCommandProvider(&MobileLogsProvider{})
}
func mobileLogsCrossUserUnavailableResponse(args *model.CommandArgs) *model.CommandResponse {
return &model.CommandResponse{
Text: args.T("api.command_mobile_logs.cross_user_unavailable.app_error"),
ResponseType: model.CommandResponseTypeEphemeral,
}
}
func logMobileLogsAttachAppLogsAudit(a *app.App, rctx request.CTX, actorUserID, targetUserID, value string) {
rec := &model.AuditRecord{
EventName: model.AuditEventUpdatePreferences,
Status: model.AuditStatusSuccess,
Actor: model.AuditEventActor{
UserId: actorUserID,
SessionId: rctx.Session().Id,
Client: rctx.UserAgent(),
IpAddress: rctx.IPAddress(),
XForwardedFor: rctx.XForwardedFor(),
},
Meta: map[string]any{
model.AuditKeyAPIPath: rctx.Path(),
model.AuditKeyClusterID: a.GetClusterId(),
},
EventData: model.AuditEventData{
Parameters: map[string]any{
"source": "slash_command/" + CmdMobileLogs,
"target_user_id": targetUserID,
"preference_category": model.PreferenceCategoryAdvancedSettings,
"preference_name": model.PreferenceNameAttachAppLogs,
"value": value,
},
PriorState: map[string]any{},
ResultState: map[string]any{},
ObjectType: "user_preference",
},
}
a.LogAuditRecWithLevel(rctx, rec, app.LevelAPI, nil)
}
func (*MobileLogsProvider) GetTrigger() string {
return CmdMobileLogs
}
func (*MobileLogsProvider) GetCommand(a *app.App, T i18n.TranslateFunc) *model.Command {
return &model.Command{
Trigger: CmdMobileLogs,
AutoComplete: true,
AutoCompleteDesc: T("api.command_mobile_logs.desc"),
AutoCompleteHint: T("api.command_mobile_logs.hint"),
DisplayName: T("api.command_mobile_logs.name"),
}
}
func (*MobileLogsProvider) DoCommand(a *app.App, rctx request.CTX, args *model.CommandArgs, message string) *model.CommandResponse {
fields := strings.Fields(message)
if len(fields) == 0 || len(fields) > 2 {
return &model.CommandResponse{
Text: args.T("api.command_mobile_logs.usage"),
ResponseType: model.CommandResponseTypeEphemeral,
}
}
action := strings.ToLower(fields[0])
if action != "on" && action != "off" && action != "status" {
return &model.CommandResponse{
Text: args.T("api.command_mobile_logs.usage"),
ResponseType: model.CommandResponseTypeEphemeral,
}
}
targetUserID := args.UserId
targetDisplayName := args.T("api.command_mobile_logs.you")
if len(fields) > 1 {
username := strings.TrimPrefix(fields[1], "@")
username = strings.ToLower(username)
if !model.IsValidUsername(username) {
return &model.CommandResponse{
Text: args.T("api.command_mobile_logs.user_not_found.app_error", map[string]any{"Username": username}),
ResponseType: model.CommandResponseTypeEphemeral,
}
}
caller, appErr := a.GetUser(rctx, args.UserId)
if appErr != nil {
rctx.Logger().Error("Failed to get caller for mobile-logs command", mlog.String("user_id", args.UserId), mlog.Err(appErr))
return &model.CommandResponse{
Text: args.T("api.command_mobile_logs.update_error.app_error"),
ResponseType: model.CommandResponseTypeEphemeral,
}
}
isSelf := username == strings.ToLower(caller.Username)
if isSelf {
targetUserID = caller.Id
targetDisplayName = "@" + caller.Username
} else {
// Cross-user: callers without system admin get one neutral outcome for any failure
// (unknown user, deactivated, disallowed target, or missing role) to avoid username
// enumeration. System admins still get explicit not-found messages for support workflows.
if !a.HasPermissionTo(rctx, args.UserId, model.PermissionManageSystem) && !a.HasPermissionTo(rctx, args.UserId, model.PermissionEditOtherUsers) {
return mobileLogsCrossUserUnavailableResponse(args)
}
callerHasManageSystem := a.HasPermissionTo(rctx, args.UserId, model.PermissionManageSystem)
targetUser, lookupErr := a.GetUserByUsername(username)
if lookupErr != nil {
if lookupErr.StatusCode == http.StatusNotFound {
if callerHasManageSystem {
return &model.CommandResponse{
Text: args.T("api.command_mobile_logs.user_not_found.app_error", map[string]any{"Username": username}),
ResponseType: model.CommandResponseTypeEphemeral,
}
}
return mobileLogsCrossUserUnavailableResponse(args)
}
rctx.Logger().Error("Failed to get user by username", mlog.String("username", username), mlog.Err(lookupErr))
if callerHasManageSystem {
return &model.CommandResponse{
Text: args.T("api.command_mobile_logs.user_not_found.app_error", map[string]any{"Username": username}),
ResponseType: model.CommandResponseTypeEphemeral,
}
}
return mobileLogsCrossUserUnavailableResponse(args)
}
if targetUser.DeleteAt != 0 {
if callerHasManageSystem {
return &model.CommandResponse{
Text: args.T("api.command_mobile_logs.user_not_found.app_error", map[string]any{"Username": username}),
ResponseType: model.CommandResponseTypeEphemeral,
}
}
return mobileLogsCrossUserUnavailableResponse(args)
}
targetUserID = targetUser.Id
targetDisplayName = "@" + targetUser.Username
if !callerHasManageSystem && targetUser.IsSystemAdmin() {
return mobileLogsCrossUserUnavailableResponse(args)
}
}
}
switch action {
case "on":
prefs := model.Preferences{
{
UserId: targetUserID,
Category: model.PreferenceCategoryAdvancedSettings,
Name: model.PreferenceNameAttachAppLogs,
Value: "true",
},
}
if err := a.UpdatePreferences(rctx, targetUserID, prefs); err != nil {
rctx.Logger().Error("Failed to update attach_app_logs preference", mlog.String("user_id", targetUserID), mlog.Err(err))
return &model.CommandResponse{
Text: args.T("api.command_mobile_logs.update_error.app_error"),
ResponseType: model.CommandResponseTypeEphemeral,
}
}
logMobileLogsAttachAppLogsAudit(a, rctx, args.UserId, targetUserID, "true")
return &model.CommandResponse{
Text: args.T("api.command_mobile_logs.enabled", map[string]any{"User": targetDisplayName}),
ResponseType: model.CommandResponseTypeEphemeral,
}
case "off":
prefs := model.Preferences{
{
UserId: targetUserID,
Category: model.PreferenceCategoryAdvancedSettings,
Name: model.PreferenceNameAttachAppLogs,
Value: "false",
},
}
if err := a.UpdatePreferences(rctx, targetUserID, prefs); err != nil {
rctx.Logger().Error("Failed to update attach_app_logs preference", mlog.String("user_id", targetUserID), mlog.Err(err))
return &model.CommandResponse{
Text: args.T("api.command_mobile_logs.update_error.app_error"),
ResponseType: model.CommandResponseTypeEphemeral,
}
}
logMobileLogsAttachAppLogsAudit(a, rctx, args.UserId, targetUserID, "false")
return &model.CommandResponse{
Text: args.T("api.command_mobile_logs.disabled", map[string]any{"User": targetDisplayName}),
ResponseType: model.CommandResponseTypeEphemeral,
}
case "status":
pref, err := a.GetPreferenceByCategoryAndNameForUser(rctx, targetUserID, model.PreferenceCategoryAdvancedSettings, model.PreferenceNameAttachAppLogs)
if err != nil {
rctx.Logger().Debug("Could not get attach_app_logs preference, defaulting to off", mlog.String("user_id", targetUserID), mlog.Err(err))
return &model.CommandResponse{
Text: args.T("api.command_mobile_logs.status_off", map[string]any{"User": targetDisplayName}),
ResponseType: model.CommandResponseTypeEphemeral,
}
}
if pref.Value == "true" {
return &model.CommandResponse{
Text: args.T("api.command_mobile_logs.status_on", map[string]any{"User": targetDisplayName}),
ResponseType: model.CommandResponseTypeEphemeral,
}
}
return &model.CommandResponse{
Text: args.T("api.command_mobile_logs.status_off", map[string]any{"User": targetDisplayName}),
ResponseType: model.CommandResponseTypeEphemeral,
}
default:
// Defensive: action is already validated to be "on", "off", or "status" above.
return &model.CommandResponse{
Text: args.T("api.command_mobile_logs.usage"),
ResponseType: model.CommandResponseTypeEphemeral,
}
}
}

View File

@@ -0,0 +1,284 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package slashcommands
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/i18n"
)
func TestMobileLogsGetCommand(t *testing.T) {
th := setup(t).initBasic(t)
cmd := &MobileLogsProvider{}
result := cmd.GetCommand(th.App, i18n.IdentityTfunc())
assert.Equal(t, CmdMobileLogs, result.Trigger)
assert.True(t, result.AutoComplete)
assert.NotEmpty(t, result.AutoCompleteDesc)
assert.NotEmpty(t, result.AutoCompleteHint)
assert.NotEmpty(t, result.DisplayName)
}
func TestMobileLogsOnSelf(t *testing.T) {
th := setup(t).initBasic(t)
cmd := &MobileLogsProvider{}
resp := cmd.DoCommand(th.App, th.Context, &model.CommandArgs{
T: i18n.IdentityTfunc(),
UserId: th.BasicUser.Id,
}, "on")
assert.Equal(t, model.CommandResponseTypeEphemeral, resp.ResponseType)
assert.Equal(t, "api.command_mobile_logs.enabled", resp.Text)
pref, err := th.App.GetPreferenceByCategoryAndNameForUser(th.Context, th.BasicUser.Id, model.PreferenceCategoryAdvancedSettings, model.PreferenceNameAttachAppLogs)
require.Nil(t, err)
assert.Equal(t, "true", pref.Value)
}
func TestMobileLogsOffSelf(t *testing.T) {
th := setup(t).initBasic(t)
cmd := &MobileLogsProvider{}
cmd.DoCommand(th.App, th.Context, &model.CommandArgs{
T: i18n.IdentityTfunc(),
UserId: th.BasicUser.Id,
}, "on")
resp := cmd.DoCommand(th.App, th.Context, &model.CommandArgs{
T: i18n.IdentityTfunc(),
UserId: th.BasicUser.Id,
}, "off")
assert.Equal(t, model.CommandResponseTypeEphemeral, resp.ResponseType)
assert.Equal(t, "api.command_mobile_logs.disabled", resp.Text)
pref, err := th.App.GetPreferenceByCategoryAndNameForUser(th.Context, th.BasicUser.Id, model.PreferenceCategoryAdvancedSettings, model.PreferenceNameAttachAppLogs)
require.Nil(t, err)
assert.Equal(t, "false", pref.Value)
}
func TestMobileLogsStatusDefault(t *testing.T) {
th := setup(t).initBasic(t)
cmd := &MobileLogsProvider{}
resp := cmd.DoCommand(th.App, th.Context, &model.CommandArgs{
T: i18n.IdentityTfunc(),
UserId: th.BasicUser.Id,
}, "status")
assert.Equal(t, model.CommandResponseTypeEphemeral, resp.ResponseType)
assert.Equal(t, "api.command_mobile_logs.status_off", resp.Text)
}
func TestMobileLogsStatusOn(t *testing.T) {
th := setup(t).initBasic(t)
cmd := &MobileLogsProvider{}
cmd.DoCommand(th.App, th.Context, &model.CommandArgs{
T: i18n.IdentityTfunc(),
UserId: th.BasicUser.Id,
}, "on")
resp := cmd.DoCommand(th.App, th.Context, &model.CommandArgs{
T: i18n.IdentityTfunc(),
UserId: th.BasicUser.Id,
}, "status")
assert.Equal(t, model.CommandResponseTypeEphemeral, resp.ResponseType)
assert.Equal(t, "api.command_mobile_logs.status_on", resp.Text)
}
func TestMobileLogsOnOtherUserWithPermission(t *testing.T) {
th := setup(t).initBasic(t)
cmd := &MobileLogsProvider{}
resp := cmd.DoCommand(th.App, th.Context, &model.CommandArgs{
T: i18n.IdentityTfunc(),
UserId: th.SystemAdminUser.Id,
}, "on @"+th.BasicUser.Username)
assert.Equal(t, model.CommandResponseTypeEphemeral, resp.ResponseType)
assert.Equal(t, "api.command_mobile_logs.enabled", resp.Text)
pref, err := th.App.GetPreferenceByCategoryAndNameForUser(th.Context, th.BasicUser.Id, model.PreferenceCategoryAdvancedSettings, model.PreferenceNameAttachAppLogs)
require.Nil(t, err)
assert.Equal(t, "true", pref.Value)
}
func TestMobileLogsOffOtherUserWithPermission(t *testing.T) {
th := setup(t).initBasic(t)
cmd := &MobileLogsProvider{}
cmd.DoCommand(th.App, th.Context, &model.CommandArgs{
T: i18n.IdentityTfunc(),
UserId: th.SystemAdminUser.Id,
}, "on @"+th.BasicUser.Username)
resp := cmd.DoCommand(th.App, th.Context, &model.CommandArgs{
T: i18n.IdentityTfunc(),
UserId: th.SystemAdminUser.Id,
}, "off @"+th.BasicUser.Username)
assert.Equal(t, model.CommandResponseTypeEphemeral, resp.ResponseType)
assert.Equal(t, "api.command_mobile_logs.disabled", resp.Text)
pref, err := th.App.GetPreferenceByCategoryAndNameForUser(th.Context, th.BasicUser.Id, model.PreferenceCategoryAdvancedSettings, model.PreferenceNameAttachAppLogs)
require.Nil(t, err)
assert.Equal(t, "false", pref.Value)
}
func TestMobileLogsStatusOtherUser(t *testing.T) {
th := setup(t).initBasic(t)
cmd := &MobileLogsProvider{}
// Default status for other user
resp := cmd.DoCommand(th.App, th.Context, &model.CommandArgs{
T: i18n.IdentityTfunc(),
UserId: th.SystemAdminUser.Id,
}, "status @"+th.BasicUser.Username)
assert.Equal(t, model.CommandResponseTypeEphemeral, resp.ResponseType)
assert.Equal(t, "api.command_mobile_logs.status_off", resp.Text)
// Enable for other user, then check status
cmd.DoCommand(th.App, th.Context, &model.CommandArgs{
T: i18n.IdentityTfunc(),
UserId: th.SystemAdminUser.Id,
}, "on @"+th.BasicUser.Username)
resp = cmd.DoCommand(th.App, th.Context, &model.CommandArgs{
T: i18n.IdentityTfunc(),
UserId: th.SystemAdminUser.Id,
}, "status @"+th.BasicUser.Username)
assert.Equal(t, model.CommandResponseTypeEphemeral, resp.ResponseType)
assert.Equal(t, "api.command_mobile_logs.status_on", resp.Text)
}
func TestMobileLogsOnOtherUserWithoutPermission(t *testing.T) {
th := setup(t).initBasic(t)
cmd := &MobileLogsProvider{}
resp := cmd.DoCommand(th.App, th.Context, &model.CommandArgs{
T: i18n.IdentityTfunc(),
UserId: th.BasicUser.Id,
}, "on @"+th.BasicUser2.Username)
assert.Equal(t, model.CommandResponseTypeEphemeral, resp.ResponseType)
assert.Equal(t, "api.command_mobile_logs.cross_user_unavailable.app_error", resp.Text)
}
func TestMobileLogsOnOtherUserWithoutAtPrefix(t *testing.T) {
th := setup(t).initBasic(t)
cmd := &MobileLogsProvider{}
resp := cmd.DoCommand(th.App, th.Context, &model.CommandArgs{
T: i18n.IdentityTfunc(),
UserId: th.SystemAdminUser.Id,
}, "on "+th.BasicUser.Username)
assert.Equal(t, model.CommandResponseTypeEphemeral, resp.ResponseType)
assert.Equal(t, "api.command_mobile_logs.enabled", resp.Text)
pref, err := th.App.GetPreferenceByCategoryAndNameForUser(th.Context, th.BasicUser.Id, model.PreferenceCategoryAdvancedSettings, model.PreferenceNameAttachAppLogs)
require.Nil(t, err)
assert.Equal(t, "true", pref.Value)
}
func TestMobileLogsNoArgs(t *testing.T) {
th := setup(t).initBasic(t)
cmd := &MobileLogsProvider{}
resp := cmd.DoCommand(th.App, th.Context, &model.CommandArgs{
T: i18n.IdentityTfunc(),
UserId: th.BasicUser.Id,
}, "")
assert.Equal(t, model.CommandResponseTypeEphemeral, resp.ResponseType)
assert.Equal(t, "api.command_mobile_logs.usage", resp.Text)
}
func TestMobileLogsTooManyArgs(t *testing.T) {
th := setup(t).initBasic(t)
cmd := &MobileLogsProvider{}
cmd.DoCommand(th.App, th.Context, &model.CommandArgs{
T: i18n.IdentityTfunc(),
UserId: th.SystemAdminUser.Id,
}, "on @"+th.BasicUser.Username)
resp := cmd.DoCommand(th.App, th.Context, &model.CommandArgs{
T: i18n.IdentityTfunc(),
UserId: th.SystemAdminUser.Id,
}, "on @"+th.BasicUser.Username+" trailing")
assert.Equal(t, model.CommandResponseTypeEphemeral, resp.ResponseType)
assert.Equal(t, "api.command_mobile_logs.usage", resp.Text)
pref, err := th.App.GetPreferenceByCategoryAndNameForUser(th.Context, th.BasicUser.Id, model.PreferenceCategoryAdvancedSettings, model.PreferenceNameAttachAppLogs)
require.Nil(t, err)
assert.Equal(t, "true", pref.Value)
}
func TestMobileLogsInvalidAction(t *testing.T) {
th := setup(t).initBasic(t)
cmd := &MobileLogsProvider{}
resp := cmd.DoCommand(th.App, th.Context, &model.CommandArgs{
T: i18n.IdentityTfunc(),
UserId: th.BasicUser.Id,
}, "invalid")
assert.Equal(t, model.CommandResponseTypeEphemeral, resp.ResponseType)
assert.Equal(t, "api.command_mobile_logs.usage", resp.Text)
}
func TestMobileLogsUserNotFound(t *testing.T) {
th := setup(t).initBasic(t)
cmd := &MobileLogsProvider{}
resp := cmd.DoCommand(th.App, th.Context, &model.CommandArgs{
T: i18n.IdentityTfunc(),
UserId: th.SystemAdminUser.Id,
}, "on @nonexistentuser12345")
assert.Equal(t, model.CommandResponseTypeEphemeral, resp.ResponseType)
assert.Equal(t, "api.command_mobile_logs.user_not_found.app_error", resp.Text)
}
func TestMobileLogsNonexistentTargetAsRegularUserReturnsNoPermission(t *testing.T) {
th := setup(t).initBasic(t)
cmd := &MobileLogsProvider{}
resp := cmd.DoCommand(th.App, th.Context, &model.CommandArgs{
T: i18n.IdentityTfunc(),
UserId: th.BasicUser.Id,
}, "on @nonexistentuser12345")
assert.Equal(t, model.CommandResponseTypeEphemeral, resp.ResponseType)
assert.Equal(t, "api.command_mobile_logs.cross_user_unavailable.app_error", resp.Text)
}
func TestMobileLogsOnSelfWithAtUsername(t *testing.T) {
th := setup(t).initBasic(t)
cmd := &MobileLogsProvider{}
resp := cmd.DoCommand(th.App, th.Context, &model.CommandArgs{
T: i18n.IdentityTfunc(),
UserId: th.BasicUser.Id,
}, "on @"+th.BasicUser.Username)
assert.Equal(t, model.CommandResponseTypeEphemeral, resp.ResponseType)
assert.Equal(t, "api.command_mobile_logs.enabled", resp.Text)
pref, err := th.App.GetPreferenceByCategoryAndNameForUser(th.Context, th.BasicUser.Id, model.PreferenceCategoryAdvancedSettings, model.PreferenceNameAttachAppLogs)
require.Nil(t, err)
assert.Equal(t, "true", pref.Value)
}
func TestMobileLogsDeactivatedUser(t *testing.T) {
th := setup(t).initBasic(t)
_, err := th.App.UpdateActive(th.Context, th.BasicUser2, false)
require.Nil(t, err)
cmd := &MobileLogsProvider{}
resp := cmd.DoCommand(th.App, th.Context, &model.CommandArgs{
T: i18n.IdentityTfunc(),
UserId: th.SystemAdminUser.Id,
}, "on @"+th.BasicUser2.Username)
assert.Equal(t, model.CommandResponseTypeEphemeral, resp.ResponseType)
assert.Equal(t, "api.command_mobile_logs.user_not_found.app_error", resp.Text)
}

View File

@@ -0,0 +1,114 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package slashcommands
import (
"errors"
"strings"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/i18n"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/app"
"github.com/mattermost/mattermost/server/v8/channels/store"
)
type msgProvider struct {
}
const (
CmdMsg = "msg"
)
func init() {
app.RegisterCommandProvider(&msgProvider{})
}
func (*msgProvider) GetTrigger() string {
return CmdMsg
}
func (*msgProvider) GetCommand(a *app.App, T i18n.TranslateFunc) *model.Command {
return &model.Command{
Trigger: CmdMsg,
AutoComplete: true,
AutoCompleteDesc: T("api.command_msg.desc"),
AutoCompleteHint: T("api.command_msg.hint"),
DisplayName: T("api.command_msg.name"),
}
}
func (*msgProvider) DoCommand(a *app.App, rctx request.CTX, args *model.CommandArgs, message string) *model.CommandResponse {
splitMessage := strings.SplitN(message, " ", 2)
parsedMessage := ""
targetUsername := ""
if len(splitMessage) > 1 {
parsedMessage = strings.SplitN(message, " ", 2)[1]
}
targetUsername = strings.SplitN(message, " ", 2)[0]
targetUsername = strings.TrimPrefix(targetUsername, "@")
userProfile, nErr := a.Srv().Store().User().GetByUsername(targetUsername)
if nErr != nil {
rctx.Logger().Error(nErr.Error())
return &model.CommandResponse{Text: args.T("api.command_msg.missing.app_error"), ResponseType: model.CommandResponseTypeEphemeral}
}
if userProfile.Id == args.UserId {
return &model.CommandResponse{Text: args.T("api.command_msg.missing.app_error"), ResponseType: model.CommandResponseTypeEphemeral}
}
canSee, err := a.UserCanSeeOtherUser(rctx, args.UserId, userProfile.Id)
if err != nil {
rctx.Logger().Error(err.Error())
return &model.CommandResponse{Text: args.T("api.command_msg.fail.app_error"), ResponseType: model.CommandResponseTypeEphemeral}
}
if !canSee {
return &model.CommandResponse{Text: args.T("api.command_msg.missing.app_error"), ResponseType: model.CommandResponseTypeEphemeral}
}
// Find the channel based on this user
channelName := model.GetDMNameFromIds(args.UserId, userProfile.Id)
targetChannelID := ""
if channel, channelErr := a.Srv().Store().Channel().GetByName(args.TeamId, channelName, true); channelErr != nil {
var nfErr *store.ErrNotFound
if errors.As(channelErr, &nfErr) {
if !a.HasPermissionTo(rctx, args.UserId, model.PermissionCreateDirectChannel) {
return &model.CommandResponse{Text: args.T("api.command_msg.permission.app_error"), ResponseType: model.CommandResponseTypeEphemeral}
}
var directChannel *model.Channel
if directChannel, err = a.GetOrCreateDirectChannel(rctx, args.UserId, userProfile.Id); err != nil {
rctx.Logger().Error(err.Error())
return &model.CommandResponse{Text: args.T(err.Id), ResponseType: model.CommandResponseTypeEphemeral}
}
targetChannelID = directChannel.Id
} else {
rctx.Logger().Error(channelErr.Error())
return &model.CommandResponse{Text: args.T("api.command_msg.dm_fail.app_error"), ResponseType: model.CommandResponseTypeEphemeral}
}
} else {
targetChannelID = channel.Id
}
if parsedMessage != "" {
post := &model.Post{}
post.Message = parsedMessage
post.ChannelId = targetChannelID
post.UserId = args.UserId
if _, _, err = a.CreatePostMissingChannel(rctx, post, true, true); err != nil {
return &model.CommandResponse{Text: args.T("api.command_msg.fail.app_error"), ResponseType: model.CommandResponseTypeEphemeral}
}
}
team, err := a.GetTeam(args.TeamId)
if err != nil {
return &model.CommandResponse{Text: args.T("api.command_msg.fail.app_error"), ResponseType: model.CommandResponseTypeEphemeral}
}
return &model.CommandResponse{GotoLocation: args.SiteURL + "/" + team.Name + "/channels/" + channelName, Text: "", ResponseType: model.CommandResponseTypeEphemeral}
}

View File

@@ -0,0 +1,93 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package slashcommands
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/i18n"
)
func TestMsgProvider(t *testing.T) {
th := setup(t).initBasic(t)
team := th.createTeam(t)
th.linkUserToTeam(t, th.BasicUser, team)
cmd := &msgProvider{}
th.removePermissionFromRole(t, model.PermissionCreateDirectChannel.Id, model.SystemUserRoleId)
// Check without permission to create a DM channel.
resp := cmd.DoCommand(th.App, th.Context, &model.CommandArgs{
T: i18n.IdentityTfunc(),
SiteURL: "http://test.url",
TeamId: team.Id,
UserId: th.BasicUser.Id,
}, "@"+th.BasicUser2.Username+" hello")
channelName := model.GetDMNameFromIds(th.BasicUser.Id, th.BasicUser2.Id)
assert.Equal(t, "api.command_msg.permission.app_error", resp.Text)
assert.Equal(t, "", resp.GotoLocation)
th.addPermissionToRole(t, model.PermissionCreateDirectChannel.Id, model.SystemUserRoleId)
// Check with permission to create a DM channel.
resp = cmd.DoCommand(th.App, th.Context, &model.CommandArgs{
T: i18n.IdentityTfunc(),
SiteURL: "http://test.url",
TeamId: team.Id,
UserId: th.BasicUser.Id,
}, "@"+th.BasicUser2.Username+" hello")
assert.Equal(t, "", resp.Text)
assert.Equal(t, "http://test.url/"+team.Name+"/channels/"+channelName, resp.GotoLocation)
// Check without permission to post to an existing DM channel.
resp = cmd.DoCommand(th.App, th.Context, &model.CommandArgs{
T: i18n.IdentityTfunc(),
SiteURL: "http://test.url",
TeamId: team.Id,
UserId: th.BasicUser.Id,
}, "@"+th.BasicUser2.Username+" hello")
assert.Equal(t, "", resp.Text)
assert.Equal(t, "http://test.url/"+team.Name+"/channels/"+channelName, resp.GotoLocation)
// Check that a guest user cannot message a user who is not in a channel/team with him
guest := th.createGuest(t)
user := th.createUser(t)
th.linkUserToTeam(t, user, team)
th.linkUserToTeam(t, guest, th.BasicTeam)
th.addUserToChannel(t, guest, th.BasicChannel)
resp = cmd.DoCommand(th.App, th.Context, &model.CommandArgs{
T: i18n.IdentityTfunc(),
SiteURL: "http://test.url",
TeamId: th.BasicTeam.Id,
UserId: guest.Id,
}, "@"+user.Username+" hello")
assert.Equal(t, "api.command_msg.missing.app_error", resp.Text)
assert.Equal(t, "", resp.GotoLocation)
// Check that a guest user can message a user who is in a channel/team with him
th.linkUserToTeam(t, user, th.BasicTeam)
th.addUserToChannel(t, user, th.BasicChannel)
resp = cmd.DoCommand(th.App, th.Context, &model.CommandArgs{
T: i18n.IdentityTfunc(),
SiteURL: "http://test.url",
TeamId: th.BasicTeam.Id,
UserId: guest.Id,
}, "@"+user.Username+" hello")
channelName = model.GetDMNameFromIds(guest.Id, user.Id)
assert.Equal(t, "", resp.Text)
assert.Equal(t, "http://test.url/"+th.BasicTeam.Name+"/channels/"+channelName, resp.GotoLocation)
}

View File

@@ -0,0 +1,82 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package slashcommands
import (
"strings"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/i18n"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/app"
)
type MuteProvider struct {
}
const (
CmdMute = "mute"
)
func init() {
app.RegisterCommandProvider(&MuteProvider{})
}
func (*MuteProvider) GetTrigger() string {
return CmdMute
}
func (*MuteProvider) GetCommand(a *app.App, T i18n.TranslateFunc) *model.Command {
return &model.Command{
Trigger: CmdMute,
AutoComplete: true,
AutoCompleteDesc: T("api.command_mute.desc"),
AutoCompleteHint: T("api.command_mute.hint"),
DisplayName: T("api.command_mute.name"),
}
}
func (*MuteProvider) DoCommand(a *app.App, rctx request.CTX, args *model.CommandArgs, message string) *model.CommandResponse {
var channel *model.Channel
var noChannelErr *model.AppError
if channel, noChannelErr = a.GetChannel(rctx, args.ChannelId); noChannelErr != nil {
return &model.CommandResponse{Text: args.T("api.command_mute.no_channel.error"), ResponseType: model.CommandResponseTypeEphemeral}
}
channelName := ""
splitMessage := strings.Split(message, " ")
// Overwrite channel with channel-handle if set
if strings.HasPrefix(message, "~") {
channelName = splitMessage[0][1:]
} else {
channelName = splitMessage[0]
}
if channelName != "" && message != "" {
channel, _ = a.Srv().Store().Channel().GetByName(channel.TeamId, channelName, true)
if channel == nil {
return &model.CommandResponse{Text: args.T("api.command_mute.error", map[string]any{"Channel": channelName}), ResponseType: model.CommandResponseTypeEphemeral}
}
}
channelMember, err := a.ToggleMuteChannel(rctx, channel.Id, args.UserId)
if err != nil {
return &model.CommandResponse{Text: args.T("api.command_mute.error", map[string]any{"Channel": channelName}), ResponseType: model.CommandResponseTypeEphemeral}
}
// Direct and Group messages won't have a nice channel title, omit it
if channel.Type == model.ChannelTypeDirect || channel.Type == model.ChannelTypeGroup {
if channelMember.NotifyProps[model.MarkUnreadNotifyProp] == model.ChannelNotifyMention {
return &model.CommandResponse{Text: args.T("api.command_mute.success_mute_direct_msg"), ResponseType: model.CommandResponseTypeEphemeral}
}
return &model.CommandResponse{Text: args.T("api.command_mute.success_unmute_direct_msg"), ResponseType: model.CommandResponseTypeEphemeral}
}
if channelMember.NotifyProps[model.MarkUnreadNotifyProp] == model.ChannelNotifyMention {
return &model.CommandResponse{Text: args.T("api.command_mute.success_mute", map[string]any{"Channel": channel.DisplayName}), ResponseType: model.CommandResponseTypeEphemeral}
}
return &model.CommandResponse{Text: args.T("api.command_mute.success_unmute", map[string]any{"Channel": channel.DisplayName}), ResponseType: model.CommandResponseTypeEphemeral}
}

View File

@@ -0,0 +1,199 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package slashcommands
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/i18n"
)
func TestMuteCommandNoChannel(t *testing.T) {
th := setup(t).initBasic(t)
if testing.Short() {
t.SkipNow()
}
channel1 := th.BasicChannel
channel1M, channel1MError := th.App.GetChannelMember(th.Context, channel1.Id, th.BasicUser.Id)
assert.Nil(t, channel1MError, "User is not a member of channel 1")
assert.NotEqual(
t,
channel1M.NotifyProps[model.MarkUnreadNotifyProp],
model.ChannelNotifyMention,
"Channel shouldn't be muted on initial setup",
)
cmd := &MuteProvider{}
resp := cmd.DoCommand(th.App, th.Context, &model.CommandArgs{
T: i18n.IdentityTfunc(),
UserId: th.BasicUser.Id,
}, "")
assert.Equal(t, "api.command_mute.no_channel.error", resp.Text)
}
func TestMuteCommandNoArgs(t *testing.T) {
th := setup(t).initBasic(t)
channel1 := th.BasicChannel
channel1M, _ := th.App.GetChannelMember(th.Context, channel1.Id, th.BasicUser.Id)
assert.Equal(t, model.ChannelNotifyAll, channel1M.NotifyProps[model.MarkUnreadNotifyProp])
cmd := &MuteProvider{}
// First mute the channel
resp := cmd.DoCommand(th.App, th.Context, &model.CommandArgs{
T: i18n.IdentityTfunc(),
ChannelId: channel1.Id,
UserId: th.BasicUser.Id,
}, "")
assert.Equal(t, "api.command_mute.success_mute", resp.Text)
// Now unmute the channel
time.Sleep(time.Millisecond)
resp = cmd.DoCommand(th.App, th.Context, &model.CommandArgs{
T: i18n.IdentityTfunc(),
ChannelId: channel1.Id,
UserId: th.BasicUser.Id,
}, "")
assert.Equal(t, "api.command_mute.success_unmute", resp.Text)
}
func TestMuteCommandSpecificChannel(t *testing.T) {
th := setup(t).initBasic(t)
if testing.Short() {
t.SkipNow()
}
channel1 := th.BasicChannel
channel2, _ := th.App.CreateChannel(th.Context, &model.Channel{
DisplayName: "AA",
Name: "aa" + model.NewId() + "a",
Type: model.ChannelTypeOpen,
TeamId: th.BasicTeam.Id,
CreatorId: th.BasicUser.Id,
}, true)
channel2M, _ := th.App.GetChannelMember(th.Context, channel2.Id, th.BasicUser.Id)
assert.Equal(t, model.ChannelNotifyAll, channel2M.NotifyProps[model.MarkUnreadNotifyProp])
cmd := &MuteProvider{}
// First mute the channel
resp := cmd.DoCommand(th.App, th.Context, &model.CommandArgs{
T: i18n.IdentityTfunc(),
ChannelId: channel1.Id,
UserId: th.BasicUser.Id,
}, channel2.Name)
assert.Equal(t, "api.command_mute.success_mute", resp.Text)
channel2M, _ = th.App.GetChannelMember(th.Context, channel2.Id, th.BasicUser.Id)
assert.Equal(t, model.ChannelNotifyMention, channel2M.NotifyProps[model.MarkUnreadNotifyProp])
// Now unmute the channel
resp = cmd.DoCommand(th.App, th.Context, &model.CommandArgs{
T: i18n.IdentityTfunc(),
ChannelId: channel1.Id,
UserId: th.BasicUser.Id,
}, "~"+channel2.Name)
assert.Equal(t, "api.command_mute.success_unmute", resp.Text)
channel2M, _ = th.App.GetChannelMember(th.Context, channel2.Id, th.BasicUser.Id)
assert.Equal(t, model.ChannelNotifyAll, channel2M.NotifyProps[model.MarkUnreadNotifyProp])
}
func TestMuteCommandNotMember(t *testing.T) {
th := setup(t).initBasic(t)
if testing.Short() {
t.SkipNow()
}
channel1 := th.BasicChannel
channel2, _ := th.App.CreateChannel(th.Context, &model.Channel{
DisplayName: "AA",
Name: "aa" + model.NewId() + "a",
Type: model.ChannelTypeOpen,
TeamId: th.BasicTeam.Id,
CreatorId: th.BasicUser.Id,
}, false)
cmd := &MuteProvider{}
// Muting a channel that the user is not a member of should return
// the same error as a non-existent channel to prevent channel enumeration
resp := cmd.DoCommand(th.App, th.Context, &model.CommandArgs{
T: i18n.IdentityTfunc(),
ChannelId: channel1.Id,
UserId: th.BasicUser.Id,
}, channel2.Name)
assert.Equal(t, "api.command_mute.error", resp.Text)
}
func TestMuteCommandNotChannel(t *testing.T) {
th := setup(t).initBasic(t)
if testing.Short() {
t.SkipNow()
}
channel1 := th.BasicChannel
cmd := &MuteProvider{}
// First mute the channel
resp := cmd.DoCommand(th.App, th.Context, &model.CommandArgs{
T: i18n.IdentityTfunc(),
ChannelId: channel1.Id,
UserId: th.BasicUser.Id,
}, "~noexists")
assert.Equal(t, "api.command_mute.error", resp.Text)
}
func TestMuteCommandDMChannel(t *testing.T) {
th := setup(t).initBasic(t)
if testing.Short() {
t.SkipNow()
}
channel2, _ := th.App.GetOrCreateDirectChannel(th.Context, th.BasicUser.Id, th.BasicUser2.Id)
channel2M, _ := th.App.GetChannelMember(th.Context, channel2.Id, th.BasicUser.Id)
assert.Equal(t, model.ChannelNotifyAll, channel2M.NotifyProps[model.MarkUnreadNotifyProp])
cmd := &MuteProvider{}
// First mute the channel
resp := cmd.DoCommand(th.App, th.Context, &model.CommandArgs{
T: i18n.IdentityTfunc(),
ChannelId: channel2.Id,
UserId: th.BasicUser.Id,
}, "")
assert.Equal(t, "api.command_mute.success_mute_direct_msg", resp.Text)
time.Sleep(time.Millisecond)
channel2M, _ = th.App.GetChannelMember(th.Context, channel2.Id, th.BasicUser.Id)
assert.Equal(t, model.ChannelNotifyMention, channel2M.NotifyProps[model.MarkUnreadNotifyProp])
// Now unmute the channel
resp = cmd.DoCommand(th.App, th.Context, &model.CommandArgs{
T: i18n.IdentityTfunc(),
ChannelId: channel2.Id,
UserId: th.BasicUser.Id,
}, "")
assert.Equal(t, "api.command_mute.success_unmute_direct_msg", resp.Text)
time.Sleep(time.Millisecond)
channel2M, _ = th.App.GetChannelMember(th.Context, channel2.Id, th.BasicUser.Id)
assert.Equal(t, model.ChannelNotifyAll, channel2M.NotifyProps[model.MarkUnreadNotifyProp])
}

View File

@@ -0,0 +1,41 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package slashcommands
import (
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/i18n"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/app"
)
type OfflineProvider struct {
}
const (
CmdOffline = "offline"
)
func init() {
app.RegisterCommandProvider(&OfflineProvider{})
}
func (*OfflineProvider) GetTrigger() string {
return CmdOffline
}
func (*OfflineProvider) GetCommand(a *app.App, T i18n.TranslateFunc) *model.Command {
return &model.Command{
Trigger: CmdOffline,
AutoComplete: true,
AutoCompleteDesc: T("api.command_offline.desc"),
DisplayName: T("api.command_offline.name"),
}
}
func (*OfflineProvider) DoCommand(a *app.App, rctx request.CTX, args *model.CommandArgs, message string) *model.CommandResponse {
a.SetStatusOffline(args.UserId, true, false)
return &model.CommandResponse{ResponseType: model.CommandResponseTypeEphemeral, Text: args.T("api.command_offline.success")}
}

View File

@@ -0,0 +1,41 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package slashcommands
import (
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/i18n"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/app"
)
type OnlineProvider struct {
}
const (
CmdOnline = "online"
)
func init() {
app.RegisterCommandProvider(&OnlineProvider{})
}
func (*OnlineProvider) GetTrigger() string {
return CmdOnline
}
func (*OnlineProvider) GetCommand(a *app.App, T i18n.TranslateFunc) *model.Command {
return &model.Command{
Trigger: CmdOnline,
AutoComplete: true,
AutoCompleteDesc: T("api.command_online.desc"),
DisplayName: T("api.command_online.name"),
}
}
func (*OnlineProvider) DoCommand(a *app.App, rctx request.CTX, args *model.CommandArgs, message string) *model.CommandResponse {
a.SetStatusOnline(args.UserId, true)
return &model.CommandResponse{ResponseType: model.CommandResponseTypeEphemeral, Text: args.T("api.command_online.success")}
}

View File

@@ -0,0 +1,33 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package slashcommands
import (
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/i18n"
"github.com/mattermost/mattermost/server/v8/channels/app"
)
type OpenProvider struct {
JoinProvider
}
const (
CmdOpen = "open"
)
func init() {
app.RegisterCommandProvider(&OpenProvider{})
}
func (open *OpenProvider) GetTrigger() string {
return CmdOpen
}
func (open *OpenProvider) GetCommand(a *app.App, T i18n.TranslateFunc) *model.Command {
cmd := open.JoinProvider.GetCommand(a, T)
cmd.Trigger = CmdOpen
cmd.DisplayName = T("api.command_open.name")
return cmd
}

View File

@@ -0,0 +1,305 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package slashcommands
import (
"cmp"
"errors"
"fmt"
"slices"
"strings"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/i18n"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/app"
)
const (
AvailableRemoteActions = "create, accept, remove, status"
)
type RemoteProvider struct {
}
// ensure RemoteProvider implements AutocompleteDynamicArgProvider
var _ app.AutocompleteDynamicArgProvider = (*RemoteProvider)(nil)
const (
CommandTriggerRemote = "secure-connection"
)
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]any{"Actions": AvailableRemoteActions}))
create := model.NewAutocompleteData("create", "", T("api.command_remote.invite.help"))
create.AddNamedTextArgument("name", T("api.command_remote.name.help"), T("api.command_remote.name.hint"), "", true)
create.AddNamedTextArgument("displayname", T("api.command_remote.displayname.help"), T("api.command_remote.displayname.hint"), "", false)
create.AddNamedTextArgument("password", T("api.command_remote.invite_password.help"), T("api.command_remote.invite_password.hint"), "", true)
accept := model.NewAutocompleteData("accept", "", T("api.command_remote.accept.help"))
accept.AddNamedTextArgument("name", T("api.command_remote.name.help"), T("api.command_remote.name.hint"), "", true)
accept.AddNamedTextArgument("displayname", T("api.command_remote.displayname.help"), T("api.command_remote.displayname.hint"), "", false)
accept.AddNamedTextArgument("password", T("api.command_remote.invite_password.help"), T("api.command_remote.invite_password.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("connectionID", T("api.command_remote.remove_remote_id.help"), "builtin:"+CommandTriggerRemote, true)
status := model.NewAutocompleteData("status", "", T("api.command_remote.status.help"))
remote.AddCommand(create)
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, rctx request.CTX, args *model.CommandArgs, message string) *model.CommandResponse {
if !a.HasPermissionTo(rctx, args.UserId, model.PermissionManageSecureConnections) {
return response(args.T("api.command_remote.permission_required", map[string]any{"Permission": "manage_secure_connections"}))
}
margs := parseNamedArgs(args.Command)
action, ok := margs[ActionKey]
if !ok {
return response(args.T("api.command_remote.missing_command", map[string]any{"Actions": AvailableRemoteActions}))
}
switch action {
case "create":
return rp.doCreate(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 response(args.T("api.command_remote.unknown_action", map[string]any{"Action": action}))
}
func (rp *RemoteProvider) GetAutoCompleteListItems(rctx request.CTX, a *app.App, commandArgs *model.CommandArgs, arg *model.AutocompleteArg, parsed, toBeParsed string) ([]model.AutocompleteListItem, error) {
if !a.HasPermissionTo(rctx, commandArgs.UserId, model.PermissionManageSecureConnections) {
return nil, errors.New("You require `manage_secure_connections` permission to manage secure connections.")
}
if arg.Name == "connectionID" && strings.Contains(parsed, " remove ") {
return getRemoteClusterAutocompleteListItems(a, true)
}
return nil, fmt.Errorf("`%s` is not a dynamic argument", arg.Name)
}
// doCreate creates and displays an encrypted invite that can be used by a remote site to establish a simple trust.
func (rp *RemoteProvider) doCreate(a *app.App, args *model.CommandArgs, margs map[string]string) *model.CommandResponse {
password := margs["password"]
if password == "" {
return response(args.T("api.command_remote.missing_empty", map[string]any{"Arg": "password"}))
}
name := margs["name"]
if name == "" {
return response(args.T("api.command_remote.missing_empty", map[string]any{"Arg": "name"}))
}
displayname := margs["displayname"]
if displayname == "" {
displayname = name
}
url := a.GetSiteURL()
if url == "" {
return response(args.T("api.command_remote.site_url_not_set"))
}
rc := &model.RemoteCluster{
Name: name,
DisplayName: displayname,
SiteURL: model.SiteURLPending + model.NewId(), // require a unique siteurl
Token: model.NewId(),
CreatorId: args.UserId,
}
rcSaved, appErr := a.AddRemoteCluster(rc)
if appErr != nil {
return response(args.T("api.command_remote.add_remote.error", map[string]any{"Error": appErr.Error()}))
}
// Display the encrypted invitation
inviteCode, err := a.CreateRemoteClusterInvite(rcSaved.RemoteId, url, rcSaved.Token, password)
if err != nil {
return response(args.T("api.command_remote.encrypt_invitation.error", map[string]any{"Error": err.Error()}))
}
return response("##### " + args.T("api.command_remote.invitation_created") + "\n" +
args.T("api.command_remote.invite_summary", map[string]any{"Command": "/secure-connection accept", "Invitation": inviteCode, "SiteURL": url}))
}
// 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 response(args.T("api.command_remote.missing_empty", map[string]any{"Arg": "password"}))
}
name := margs["name"]
if name == "" {
return response(args.T("api.command_remote.missing_empty", map[string]any{"Arg": "name"}))
}
displayname := margs["displayname"]
if displayname == "" {
displayname = name
}
blob := margs["invite"]
if blob == "" {
return response(args.T("api.command_remote.missing_empty", map[string]any{"Arg": "invite"}))
}
// invite is encoded as base64 and encrypted
invite, dErr := a.DecryptRemoteClusterInvite(blob, password)
if dErr != nil {
return response(args.T("api.command_remote.decode_invitation.error", map[string]any{"Error": dErr.Error()}))
}
rcs, _ := a.GetRemoteClusterService()
if rcs == nil {
return response(args.T("api.command_remote.service_not_enabled"))
}
url := a.GetSiteURL()
if url == "" {
return response(args.T("api.command_remote.site_url_not_set"))
}
rc, err := rcs.AcceptInvitation(invite, name, displayname, args.UserId, url, "")
if err != nil {
return response(args.T("api.command_remote.accept_invitation.error", map[string]any{"Error": err.Error()}))
}
return response("##### " + args.T("api.command_remote.accept_invitation", map[string]any{"SiteURL": rc.GetSiteURL()}))
}
// 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["connectionID"]
if !ok {
return response(args.T("api.command_remote.missing_empty", map[string]any{"Arg": "remoteId"}))
}
deleted, err := a.DeleteRemoteCluster(id)
if err != nil {
response(args.T("api.command_remote.remove_remote.error", map[string]any{"Error": err.Error()}))
}
result := "removed"
if !deleted {
result = "**NOT FOUND**"
}
return response("##### " + args.T("api.command_remote.cluster_removed", map[string]any{"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(0, 999999, model.RemoteClusterQueryFilter{IncludeDeleted: true})
if err != nil {
return response(args.T("api.command_remote.fetch_status.error", map[string]any{"Error": err.Error()}))
}
if len(list) == 0 {
return response("** " + args.T("api.command_remote.remotes_not_found") + " **")
}
// Show active connections first, then deleted ones, ordered by creation time within each group.
slices.SortStableFunc(list, func(a, b *model.RemoteCluster) int {
aDeleted := a.DeleteAt != 0
bDeleted := b.DeleteAt != 0
if aDeleted != bDeleted {
if aDeleted {
return 1
}
return -1
}
return cmp.Compare(a.CreateAt, b.CreateAt)
})
var sb strings.Builder
fmt.Fprintf(&sb, "%s \n", args.T("api.command_remote.remote_table_header"))
// | Secure Connection | Display name | ConnectionID | Site URL | Default Team | Invite accepted | Online | Last ping | Deleted |
fmt.Fprintf(&sb, "| :---- | :---- | :---- | :---- | :---- | :---- | :---- | :---- | :---- |\n")
for _, rc := range list {
accepted := formatBool(args.T, rc.IsConfirmed())
online := formatBool(args.T, isOnline(rc.LastPingAt))
lastPing := formatTimestamp(rc.LastPingAt)
deleted := formatBool(args.T, rc.DeleteAt != 0)
fmt.Fprintf(&sb, "| %s | %s | %s | %s | %s | %s | %s | %s | %s |\n", rc.Name, rc.DisplayName, rc.RemoteId, rc.GetSiteURL(), rc.DefaultTeamId, accepted, online, lastPing, deleted)
}
return response(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(0, 999999, 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.GetSiteURL())}
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(0, 999999, 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.GetSiteURL())}
list = append(list, item)
}
return list, nil
}

View File

@@ -0,0 +1,106 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package slashcommands
import (
"strings"
"testing"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
)
func TestRemoteProviderDoStatus(t *testing.T) {
th := setupForSharedChannels(t).initBasic(t)
th.addPermissionToRole(t, model.PermissionManageSecureConnections.Id, th.BasicUser.Roles)
// seedRemote creates a remote cluster. When deleted is true it is soft-deleted
// after creation so it carries a non-zero DeleteAt, mirroring a removed connection.
seedRemote := func(t *testing.T, displayName string, createAt int64, deleted bool) {
t.Helper()
rc, appErr := th.App.AddRemoteCluster(&model.RemoteCluster{
RemoteId: model.NewId(),
Name: "remote-" + model.NewId(),
DisplayName: displayName,
SiteURL: "https://" + model.NewId() + ".example.com",
Token: model.NewId(),
CreateAt: createAt,
CreatorId: th.BasicUser.Id,
})
require.Nil(t, appErr)
if deleted {
_, appErr = th.App.DeleteRemoteCluster(rc.RemoteId)
require.Nil(t, appErr)
}
}
// The store returns rows ordered by DisplayName (see sqlRemoteClusterStore.GetAll),
// so without the fix the rows come back interleaved: AAA, BBB, CCC, DDD.
//
// CreateAt values are chosen so the fix's behavior is unambiguous:
// - Active group: CCC (100) must sort before AAA (300), proving the CreateAt
// ordering overrides the store's alphabetical order.
// - Deleted group: BBB and DDD share a CreateAt, so the stable sort must
// preserve their store order (BBB before DDD).
// Expected final order: CCC, AAA, BBB, DDD.
seedRemote(t, "AAA Active", 300, false)
seedRemote(t, "BBB Deleted", 200, true)
seedRemote(t, "CCC Active", 100, false)
seedRemote(t, "DDD Deleted", 200, true)
args := &model.CommandArgs{
T: func(s string, args ...any) string { return s },
UserId: th.BasicUser.Id,
TeamId: th.BasicTeam.Id,
ChannelId: th.BasicChannel.Id,
Command: "/secure-connection status",
}
resp := (&RemoteProvider{}).DoCommand(th.App, th.Context, args, "")
require.NotNil(t, resp)
output := resp.Text
// separatorLine returns the Markdown table separator row (the line made up of
// alignment cells) from the command output.
separatorLine := func() string {
for line := range strings.SplitSeq(output, "\n") {
if strings.HasPrefix(strings.TrimSpace(line), "| :----") {
return line
}
}
return ""
}
t.Run("header separator has exactly nine cells matching the columns", func(t *testing.T) {
sep := separatorLine()
require.NotEmpty(t, sep, "expected a Markdown separator row in the output")
require.Equal(t, "| :---- | :---- | :---- | :---- | :---- | :---- | :---- | :---- | :---- |", sep,
"separator row must have exactly nine cells to match the nine-column header and data rows")
require.Equal(t, 9, strings.Count(sep, ":----"), "separator must contain exactly nine alignment cells")
})
t.Run("data rows have nine cells matching the header", func(t *testing.T) {
for line := range strings.SplitSeq(output, "\n") {
if strings.Contains(line, "AAA Active") {
// A row with 9 cells is delimited by 10 pipe characters.
require.Equal(t, 10, strings.Count(line, "|"), "each data row must have nine cells")
return
}
}
require.Fail(t, "expected to find the AAA Active data row in the output")
})
t.Run("active connections sort before deleted ones, by CreateAt then stably", func(t *testing.T) {
rowOrder := []string{"CCC Active", "AAA Active", "BBB Deleted", "DDD Deleted"}
lastIdx := -1
for _, name := range rowOrder {
idx := strings.Index(output, name)
require.GreaterOrEqual(t, idx, 0, "expected %q to appear in the status output", name)
require.Greater(t, idx, lastIdx, "expected %q to appear after the previous row", name)
lastIdx = idx
}
})
}

View File

@@ -0,0 +1,152 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package slashcommands
import (
"strings"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/i18n"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/app"
)
type RemoveProvider struct {
}
type KickProvider struct {
}
const (
CmdRemove = "remove"
CmdKick = "kick"
)
func init() {
app.RegisterCommandProvider(&RemoveProvider{})
app.RegisterCommandProvider(&KickProvider{})
}
func (*RemoveProvider) GetTrigger() string {
return CmdRemove
}
func (*KickProvider) GetTrigger() string {
return CmdKick
}
func (*RemoveProvider) GetCommand(a *app.App, T i18n.TranslateFunc) *model.Command {
return &model.Command{
Trigger: CmdRemove,
AutoComplete: true,
AutoCompleteDesc: T("api.command_remove.desc"),
AutoCompleteHint: T("api.command_remove.hint"),
DisplayName: T("api.command_remove.name"),
}
}
func (*KickProvider) GetCommand(a *app.App, T i18n.TranslateFunc) *model.Command {
return &model.Command{
Trigger: CmdKick,
AutoComplete: true,
AutoCompleteDesc: T("api.command_remove.desc"),
AutoCompleteHint: T("api.command_remove.hint"),
DisplayName: T("api.command_kick.name"),
}
}
func (*RemoveProvider) DoCommand(a *app.App, rctx request.CTX, args *model.CommandArgs, message string) *model.CommandResponse {
return doCommand(a, rctx, args, message)
}
func (*KickProvider) DoCommand(a *app.App, rctx request.CTX, args *model.CommandArgs, message string) *model.CommandResponse {
return doCommand(a, rctx, args, message)
}
func doCommand(a *app.App, rctx request.CTX, args *model.CommandArgs, message string) *model.CommandResponse {
channel, err := a.GetChannel(rctx, args.ChannelId)
if err != nil {
return &model.CommandResponse{
Text: args.T("api.command_channel_remove.channel.app_error"),
ResponseType: model.CommandResponseTypeEphemeral,
}
}
switch channel.Type {
case model.ChannelTypeOpen:
if ok, _ := a.HasPermissionToChannel(rctx, args.UserId, args.ChannelId, model.PermissionManagePublicChannelMembers); !ok {
return &model.CommandResponse{
Text: args.T("api.command_remove.permission.app_error"),
ResponseType: model.CommandResponseTypeEphemeral,
}
}
case model.ChannelTypePrivate:
if ok, _ := a.HasPermissionToChannel(rctx, args.UserId, args.ChannelId, model.PermissionManagePrivateChannelMembers); !ok {
return &model.CommandResponse{
Text: args.T("api.command_remove.permission.app_error"),
ResponseType: model.CommandResponseTypeEphemeral,
}
}
default:
return &model.CommandResponse{
Text: args.T("api.command_remove.direct_group.app_error"),
ResponseType: model.CommandResponseTypeEphemeral,
}
}
if message == "" {
return &model.CommandResponse{
Text: args.T("api.command_remove.message.app_error"),
ResponseType: model.CommandResponseTypeEphemeral,
}
}
targetUsername := ""
targetUsername = strings.SplitN(message, " ", 2)[0]
targetUsername = strings.TrimPrefix(targetUsername, "@")
userProfile, nErr := a.Srv().Store().User().GetByUsername(targetUsername)
if nErr != nil {
rctx.Logger().Error(nErr.Error())
return &model.CommandResponse{
Text: args.T("api.command_remove.missing.app_error"),
ResponseType: model.CommandResponseTypeEphemeral,
}
}
if userProfile.DeleteAt != 0 {
return &model.CommandResponse{
Text: args.T("api.command_remove.missing.app_error"),
ResponseType: model.CommandResponseTypeEphemeral,
}
}
_, err = a.GetChannelMember(rctx, args.ChannelId, userProfile.Id)
if err != nil {
nameFormat := *a.Config().TeamSettings.TeammateNameDisplay
return &model.CommandResponse{
Text: args.T("api.command_remove.user_not_in_channel", map[string]any{
"Username": userProfile.GetDisplayName(nameFormat),
}),
ResponseType: model.CommandResponseTypeEphemeral,
}
}
if err = a.RemoveUserFromChannel(rctx, userProfile.Id, args.UserId, channel); err != nil {
var text string
if err.Id == "api.channel.remove_members.denied" {
text = args.T("api.command_remove.group_constrained_user_denied")
} else {
text = args.T(err.Id, map[string]any{
"Channel": model.DefaultChannelName,
})
}
return &model.CommandResponse{
Text: text,
ResponseType: model.CommandResponseTypeEphemeral,
}
}
return &model.CommandResponse{}
}

View File

@@ -0,0 +1,132 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package slashcommands
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
)
func TestRemoveProviderDoCommand(t *testing.T) {
th := setup(t).initBasic(t)
rp := RemoveProvider{}
publicChannel, _ := th.App.CreateChannel(th.Context, &model.Channel{
DisplayName: "AA",
Name: "aa" + model.NewId() + "a",
Type: model.ChannelTypeOpen,
TeamId: th.BasicTeam.Id,
CreatorId: th.BasicUser.Id,
}, false)
privateChannel, _ := th.App.CreateChannel(th.Context, &model.Channel{
DisplayName: "BB",
Name: "aa" + model.NewId() + "a",
Type: model.ChannelTypeOpen,
TeamId: th.BasicTeam.Id,
CreatorId: th.BasicUser.Id,
}, false)
targetUser := th.createUser(t)
_, _, err := th.App.AddUserToTeam(th.Context, th.BasicTeam.Id, targetUser.Id, targetUser.Id)
require.Nil(t, err)
_, err = th.App.AddUserToChannel(th.Context, targetUser, publicChannel, false)
require.Nil(t, err)
_, err = th.App.AddUserToChannel(th.Context, targetUser, privateChannel, false)
require.Nil(t, err)
// Try a public channel *without* permission.
args := &model.CommandArgs{
T: func(s string, args ...any) string { return s },
ChannelId: publicChannel.Id,
UserId: th.BasicUser.Id,
}
actual := rp.DoCommand(th.App, th.Context, args, targetUser.Username).Text
assert.Equal(t, "api.command_remove.permission.app_error", actual)
// Try a public channel *with* permission.
_, err = th.App.AddUserToChannel(th.Context, th.BasicUser, publicChannel, false)
require.Nil(t, err)
args = &model.CommandArgs{
T: func(s string, args ...any) string { return s },
ChannelId: publicChannel.Id,
UserId: th.BasicUser.Id,
}
actual = rp.DoCommand(th.App, th.Context, args, targetUser.Username).Text
assert.Equal(t, "", actual)
// Try a private channel *without* permission.
args = &model.CommandArgs{
T: func(s string, args ...any) string { return s },
ChannelId: privateChannel.Id,
UserId: th.BasicUser.Id,
}
actual = rp.DoCommand(th.App, th.Context, args, targetUser.Username).Text
assert.Equal(t, "api.command_remove.permission.app_error", actual)
// Try a private channel *with* permission.
_, err = th.App.AddUserToChannel(th.Context, th.BasicUser, privateChannel, false)
require.Nil(t, err)
args = &model.CommandArgs{
T: func(s string, args ...any) string { return s },
ChannelId: privateChannel.Id,
UserId: th.BasicUser.Id,
}
actual = rp.DoCommand(th.App, th.Context, args, targetUser.Username).Text
assert.Equal(t, "", actual)
// Try a group channel
user1 := th.createUser(t)
user2 := th.createUser(t)
groupChannel := th.createGroupChannel(t, user1, user2)
args = &model.CommandArgs{
T: func(s string, args ...any) string { return s },
ChannelId: groupChannel.Id,
UserId: th.BasicUser.Id,
}
actual = rp.DoCommand(th.App, th.Context, args, user1.Username).Text
assert.Equal(t, "api.command_remove.direct_group.app_error", actual)
// Try a direct channel *with* being a member.
directChannel := th.createDmChannel(t, user1)
args = &model.CommandArgs{
T: func(s string, args ...any) string { return s },
ChannelId: directChannel.Id,
UserId: th.BasicUser.Id,
}
actual = rp.DoCommand(th.App, th.Context, args, user1.Username).Text
assert.Equal(t, "api.command_remove.direct_group.app_error", actual)
// Try a public channel with a deactivated user.
deactivatedUser := th.createUser(t)
_, _, err = th.App.AddUserToTeam(th.Context, th.BasicTeam.Id, deactivatedUser.Id, deactivatedUser.Id)
require.Nil(t, err)
_, err = th.App.AddUserToChannel(th.Context, deactivatedUser, publicChannel, false)
require.Nil(t, err)
_, err = th.App.UpdateActive(th.Context, deactivatedUser, false)
require.Nil(t, err)
args = &model.CommandArgs{
T: func(s string, args ...any) string { return s },
ChannelId: publicChannel.Id,
UserId: th.BasicUser.Id,
}
actual = rp.DoCommand(th.App, th.Context, args, deactivatedUser.Username).Text
assert.Equal(t, "api.command_remove.missing.app_error", actual)
}

View File

@@ -0,0 +1,44 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package slashcommands
import (
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/i18n"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/app"
)
type SearchProvider struct {
}
const (
CmdSearch = "search"
)
func init() {
app.RegisterCommandProvider(&SearchProvider{})
}
func (search *SearchProvider) GetTrigger() string {
return CmdSearch
}
func (search *SearchProvider) GetCommand(a *app.App, T i18n.TranslateFunc) *model.Command {
return &model.Command{
Trigger: CmdSearch,
AutoComplete: true,
AutoCompleteDesc: T("api.command_search.desc"),
AutoCompleteHint: T("api.command_search.hint"),
DisplayName: T("api.command_search.name"),
}
}
func (search *SearchProvider) DoCommand(a *app.App, rctx request.CTX, args *model.CommandArgs, message string) *model.CommandResponse {
// This command is handled client-side and shouldn't hit the server.
return &model.CommandResponse{
Text: args.T("api.command_search.unsupported.app_error"),
ResponseType: model.CommandResponseTypeEphemeral,
}
}

View File

@@ -0,0 +1,44 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package slashcommands
import (
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/i18n"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/app"
)
type SettingsProvider struct {
}
const (
CmdSettings = "settings"
)
func init() {
app.RegisterCommandProvider(&SettingsProvider{})
}
func (settings *SettingsProvider) GetTrigger() string {
return CmdSettings
}
func (settings *SettingsProvider) GetCommand(a *app.App, T i18n.TranslateFunc) *model.Command {
return &model.Command{
Trigger: CmdSettings,
AutoComplete: true,
AutoCompleteDesc: T("api.command_settings.desc"),
AutoCompleteHint: "",
DisplayName: T("api.command_settings.name"),
}
}
func (settings *SettingsProvider) DoCommand(a *app.App, rctx request.CTX, args *model.CommandArgs, message string) *model.CommandResponse {
// This command is handled client-side and shouldn't hit the server.
return &model.CommandResponse{
Text: args.T("api.command_settings.unsupported.app_error"),
ResponseType: model.CommandResponseTypeEphemeral,
}
}

View File

@@ -0,0 +1,310 @@
// 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/public/model"
"github.com/mattermost/mattermost/server/public/shared/i18n"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/app"
)
type ShareProvider struct {
}
// ensure ShareProvide implements AutocompleteDynamicArgProvider
var _ app.AutocompleteDynamicArgProvider = (*ShareProvider)(nil)
const (
CommandTriggerShare = "share-channel"
AvailableShareActions = "invite, uninvite, unshare, 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]any{"Actions": AvailableShareActions}))
inviteRemote := model.NewAutocompleteData("invite", "", T("api.command_share.invite_remote.help"))
inviteRemote.AddNamedDynamicListArgument("connectionID", T("api.command_share.remote_id.help"), "builtin:"+CommandTriggerShare, true)
inviteRemote.AddNamedTextArgument("readonly", T("api.command_share.share_read_only.help"), T("api.command_share.share_read_only.hint"), "Y|N|y|n", false)
unInviteRemote := model.NewAutocompleteData("uninvite", "", T("api.command_share.uninvite_remote.help"))
unInviteRemote.AddNamedDynamicListArgument("connectionID", T("api.command_share.uninvite_remote_id.help"), "builtin:"+CommandTriggerShare, true)
unshareChannel := model.NewAutocompleteData("unshare", "", T("api.command_share.unshare_channel.help"))
status := model.NewAutocompleteData("status", "", T("api.command_share.channel_status.help"))
share.AddCommand(inviteRemote)
share.AddCommand(unInviteRemote)
share.AddCommand(unshareChannel)
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(rctx request.CTX, a *app.App, commandArgs *model.CommandArgs, arg *model.AutocompleteArg, parsed, toBeParsed string) ([]model.AutocompleteListItem, error) {
if !a.HasPermissionTo(rctx, commandArgs.UserId, model.PermissionManageSharedChannels) {
return []model.AutocompleteListItem{}, nil
}
switch {
case strings.Contains(parsed, " share "):
return sp.getAutoCompleteShareChannel(rctx, a, commandArgs, arg)
case strings.Contains(parsed, " invite "):
return sp.getAutoCompleteInviteRemote(a, commandArgs, arg)
case strings.Contains(parsed, " uninvite "):
return sp.getAutoCompleteUnInviteRemote(a, commandArgs, arg)
}
return nil, errors.New("invalid action")
}
func (sp *ShareProvider) getAutoCompleteShareChannel(rctx request.CTX, a *app.App, commandArgs *model.CommandArgs, arg *model.AutocompleteArg) ([]model.AutocompleteListItem, error) {
channel, err := a.GetChannel(rctx, 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 "connectionID":
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 "connectionID":
return getRemoteClusterAutocompleteListItems(a, true)
default:
return nil, fmt.Errorf("%s not a dynamic argument", arg.Name)
}
}
func (sp *ShareProvider) DoCommand(a *app.App, rctx request.CTX, args *model.CommandArgs, message string) *model.CommandResponse {
if !a.HasPermissionTo(rctx, args.UserId, model.PermissionManageSharedChannels) {
return response(args.T("api.command_share.permission_required", map[string]any{"Permission": "manage_shared_channels"}))
}
// Only check that the services are non-nil (licensed and configured). Do not check Active()
// here because Active() only returns true on the cluster leader node. In HA deployments,
// slash commands can be routed to any node via the load balancer, not just the leader.
if a.Srv().GetSharedChannelSyncService() == nil {
return response(args.T("api.command_share.service_disabled"))
}
if a.Srv().GetRemoteClusterService() == nil {
return response(args.T("api.command_remote.service_disabled"))
}
margs := parseNamedArgs(args.Command)
action, ok := margs[ActionKey]
if !ok {
return response(args.T("api.command_share.missing_action", map[string]any{"Actions": AvailableShareActions}))
}
switch action {
case "share":
return sp.doShareChannel(a, rctx, args, margs)
case "unshare":
return sp.doUnshareChannel(a, args, margs)
case "invite":
return sp.doInviteRemote(a, rctx, args, margs)
case "uninvite":
return sp.doUninviteRemote(a, args, margs)
case "status":
return sp.doStatus(a, args, margs)
}
return response(args.T("api.command_share.unknown_action", map[string]any{"Action": action, "Actions": AvailableShareActions}))
}
func (sp *ShareProvider) doShareChannel(a *app.App, rctx request.CTX, args *model.CommandArgs, margs map[string]string) *model.CommandResponse {
// fetch defaults for missing channel props
channel, errApp := a.GetChannel(rctx, args.ChannelId)
if errApp != nil {
return response(args.T("api.command_share.share_channel.error", map[string]any{"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 response(args.T("api.command_share.invalid_value.error", map[string]any{"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.ShareChannel(rctx, sc); err != nil {
return response(args.T("api.command_share.share_channel.error", map[string]any{"Error": err.Error()}))
}
return response("##### " + args.T("api.command_share.channel_shared"))
}
func (sp *ShareProvider) doUnshareChannel(a *app.App, args *model.CommandArgs, margs map[string]string) *model.CommandResponse {
deleted, err := a.UnshareChannel(args.ChannelId)
if err != nil {
return response(args.T("api.command_share.shared_channel_unshare.error", map[string]any{"Error": err.Error()}))
}
if !deleted {
return response(args.T("api.command_share.not_shared_channel_unshare"))
}
return response("##### " + args.T("api.command_share.shared_channel_unavailable"))
}
func (sp *ShareProvider) doInviteRemote(a *app.App, rctx request.CTX, args *model.CommandArgs, margs map[string]string) (resp *model.CommandResponse) {
remoteID, ok := margs["connectionID"]
if !ok || remoteID == "" {
return response(args.T("api.command_share.must_specify_valid_remote"))
}
hasRemote, err := a.HasRemote(args.ChannelId, remoteID)
if err != nil {
return response(args.T("api.command_share.fetch_remote.error", map[string]any{"Error": err.Error()}))
}
if hasRemote {
return response(args.T("api.command_share.remote_already_invited"))
}
// Check if channel is shared or not.
// TODO: have the share channels service generate the "channel has been shared post" and this section can be removed since
// since `a.InviteRemoteToChannel` will share the channel automatically.
hasChan, err := a.HasSharedChannel(args.ChannelId)
if err != nil {
return response(args.T("api.command_share.check_channel_exist.error", map[string]any{"ChannelID": args.ChannelId, "Error": err.Error()}))
}
if !hasChan {
// If it doesn't exist, then create it.
resp2 := sp.doShareChannel(a, rctx, args, margs)
// We modify the outgoing response by prepending the text
// from the shareChannel response.
defer func() {
resp.Text = resp2.Text + "\n" + resp.Text
}()
}
rc, appErr := a.GetRemoteCluster(remoteID, false)
if appErr != nil {
return response(args.T("api.command_share.remote_id_invalid.error", map[string]any{"Error": appErr.Error()}))
}
if err = a.InviteRemoteToChannel(args.ChannelId, remoteID, args.UserId, true); err != nil {
return response(args.T("api.command_share.invite_remote_to_channel.error", map[string]any{"Error": err.Error()}))
}
return response("##### " + args.T("api.command_share.invitation_sent", map[string]any{"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["connectionID"]
if !ok || remoteID == "" {
return response(args.T("api.command_share.remote_not_valid"))
}
err := a.UninviteRemoteFromChannel(args.ChannelId, remoteID)
if err != nil {
return response(err.Error())
}
return response("##### " + args.T("api.command_share.remote_uninvited", map[string]any{"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 response(args.T("api.command_share.fetch_remote_status.error", map[string]any{"Error": err.Error()}))
}
if len(statuses) == 0 {
return response(args.T("api.command_share.no_remote_invited"))
}
var sb strings.Builder
fmt.Fprintf(&sb, "%s\n\n", args.T("api.command_share.channel_status_id", map[string]any{"ChannelId": statuses[0].ChannelId}))
fmt.Fprintf(&sb, "%s \n", args.T("api.command_share.remote_table_header"))
// "| Secure Connection | SiteURL | ReadOnly | InviteAccepted | Online | Last Sync |"
fmt.Fprintf(&sb, "| ---- | ---- | ---- | ---- | ---- | ---- | \n")
for _, status := range statuses {
readonly := formatBool(args.T, status.ReadOnly)
accepted := formatBool(args.T, status.IsInviteAccepted)
online := formatBool(args.T, isOnline(status.LastPingAt))
lastSync := formatTimestamp(status.LastSyncAt)
fmt.Fprintf(&sb, "| %s | %s | %s | %s | %s | %s |\n",
status.DisplayName, status.SiteURL, readonly, accepted, online, lastSync)
}
return response(sb.String())
}

View File

@@ -0,0 +1,511 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package slashcommands
import (
"bytes"
"testing"
"github.com/stretchr/testify/assert"
"github.com/mattermost/mattermost/server/v8/channels/testlib"
"github.com/mattermost/mattermost/server/v8/channels/app"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
)
func setupForSharedChannels(tb testing.TB) *TestHelper {
return setupConfig(tb, func(cfg *model.Config) {
*cfg.ConnectedWorkspacesSettings.EnableRemoteClusterService = true
*cfg.ConnectedWorkspacesSettings.EnableSharedChannels = true
})
}
func TestShareProviderDoCommand(t *testing.T) {
t.Run("share command sends a websocket channel updated event", func(t *testing.T) {
th := setupForSharedChannels(t).initBasic(t)
th.addPermissionToRole(t, model.PermissionManageSharedChannels.Id, th.BasicUser.Roles)
mockSyncService := app.NewMockSharedChannelService(th.Server.GetSharedChannelSyncService())
th.Server.SetSharedChannelSyncService(mockSyncService)
testCluster := &testlib.FakeClusterInterface{}
th.Server.Platform().SetCluster(testCluster)
commandProvider := ShareProvider{}
channel := th.CreateChannel(t, th.BasicTeam, WithShared(false))
args := &model.CommandArgs{
T: func(s string, args ...any) string { return s },
ChannelId: channel.Id,
UserId: th.BasicUser.Id,
TeamId: th.BasicTeam.Id,
Command: "/share-channel share",
}
response := commandProvider.DoCommand(th.App, th.Context, args, "")
require.Equal(t, "##### "+args.T("api.command_share.channel_shared"), response.Text)
channelUpdatedMessages := testCluster.SelectMessages(func(msg *model.ClusterMessage) bool {
event, err := model.WebSocketEventFromJSON(bytes.NewReader(msg.Data))
return err == nil && event.EventType() == model.WebsocketEventChannelUpdated
})
assert.Len(t, channelUpdatedMessages, 1) // one msg for share creation
})
t.Run("unshare command sends a websocket channel updated event", func(t *testing.T) {
th := setupForSharedChannels(t).initBasic(t)
th.addPermissionToRole(t, model.PermissionManageSharedChannels.Id, th.BasicUser.Roles)
mockSyncService := app.NewMockSharedChannelService(th.Server.GetSharedChannelSyncService())
th.Server.SetSharedChannelSyncService(mockSyncService)
testCluster := &testlib.FakeClusterInterface{}
th.Server.Platform().SetCluster(testCluster)
commandProvider := ShareProvider{}
channel := th.CreateChannel(t, th.BasicTeam, WithShared(true))
args := &model.CommandArgs{
T: func(s string, args ...any) string { return s },
ChannelId: channel.Id,
UserId: th.BasicUser.Id,
TeamId: th.BasicTeam.Id,
Command: "/share-channel unshare",
}
response := commandProvider.DoCommand(th.App, th.Context, args, "")
require.Equal(t, "##### "+args.T("api.command_share.shared_channel_unavailable"), response.Text)
channelUpdatedMessages := testCluster.SelectMessages(func(msg *model.ClusterMessage) bool {
event, err := model.WebSocketEventFromJSON(bytes.NewReader(msg.Data))
return err == nil && event.EventType() == model.WebsocketEventChannelUpdated
})
require.Len(t, channelUpdatedMessages, 2) // one msg for share creation, one for unshare.
})
t.Run("invite remote to channel shared with us", func(t *testing.T) {
th := setupForSharedChannels(t).initBasic(t)
th.addPermissionToRole(t, model.PermissionManageSharedChannels.Id, th.BasicUser.Roles)
mockSyncService := app.NewMockSharedChannelService(th.Server.GetSharedChannelSyncService())
th.Server.SetSharedChannelSyncService(mockSyncService)
testCluster := &testlib.FakeClusterInterface{}
th.Server.Platform().SetCluster(testCluster)
remoteCluster, err := th.App.AddRemoteCluster(&model.RemoteCluster{
RemoteId: model.NewId(),
Name: "remote",
SiteURL: "example.com",
Token: model.NewId(),
Topics: "topic",
CreateAt: model.GetMillis(),
LastPingAt: model.GetMillis(),
CreatorId: model.NewId(),
})
require.Nil(t, err)
commandProvider := ShareProvider{}
channel := th.CreateChannel(t, th.BasicTeam, WithShared(true)) // will create with generated remoteID
args := &model.CommandArgs{
T: func(s string, args ...any) string { return s },
ChannelId: channel.Id,
UserId: th.BasicUser.Id,
TeamId: th.BasicTeam.Id,
Command: "/share-channel invite --connectionID " + remoteCluster.RemoteId,
}
response := commandProvider.DoCommand(th.App, th.Context, args, "")
require.Contains(t, response.Text, args.T("api.command_share.invite_remote_to_channel.error"))
})
}
func TestShareProviderDoStatus(t *testing.T) {
seedInvitedRemote := func(t *testing.T, th *TestHelper, channelID string) *model.SharedChannelRemote {
t.Helper()
rc, err := th.App.AddRemoteCluster(&model.RemoteCluster{
RemoteId: model.NewId(),
Name: "remote-" + model.NewId(),
DisplayName: "Remote Status Display",
SiteURL: "https://remote-status.example.com",
Token: model.NewId(),
Topics: "topic",
CreateAt: model.GetMillis(),
LastPingAt: model.GetMillis(),
CreatorId: model.NewId(),
})
require.Nil(t, err)
scr, saveErr := th.App.SaveSharedChannelRemote(&model.SharedChannelRemote{
Id: model.NewId(),
ChannelId: channelID,
RemoteId: rc.RemoteId,
CreatorId: th.BasicUser.Id,
IsInviteAccepted: true,
IsInviteConfirmed: true,
})
require.NoError(t, saveErr)
return scr
}
statusArgs := func(th *TestHelper, channelID string) *model.CommandArgs {
return &model.CommandArgs{
T: func(s string, args ...any) string { return s },
ChannelId: channelID,
UserId: th.BasicUser.Id,
TeamId: th.BasicTeam.Id,
Command: "/share-channel status",
}
}
t.Run("renders the last sync timestamp for a remote that has synced", func(t *testing.T) {
th := setupForSharedChannels(t).initBasic(t)
th.addPermissionToRole(t, model.PermissionManageSharedChannels.Id, th.BasicUser.Roles)
mockSyncService := app.NewMockSharedChannelService(th.Server.GetSharedChannelSyncService())
th.Server.SetSharedChannelSyncService(mockSyncService)
channel := th.CreateChannel(t, th.BasicTeam, WithShared(true))
scr := seedInvitedRemote(t, th, channel.Id)
syncAt := model.GetMillis() - 5000
require.NoError(t, th.App.Srv().Store().SharedChannel().UpdateRemoteCursor(scr.Id, model.GetPostsSinceForSyncCursor{
LastPostUpdateAt: syncAt,
LastPostUpdateID: model.NewId(),
}))
commandProvider := ShareProvider{}
response := commandProvider.DoCommand(th.App, th.Context, statusArgs(th, channel.Id), "")
require.Contains(t, response.Text, "Remote Status Display")
assert.Contains(t, response.Text, "Today ", "expected a rendered timestamp in the Last Sync column, got:\n%s", response.Text)
assert.NotContains(t, response.Text, "| -- |", "Last Sync column should not be the placeholder for a synced remote:\n%s", response.Text)
})
t.Run("renders a placeholder when the remote has never synced", func(t *testing.T) {
th := setupForSharedChannels(t).initBasic(t)
th.addPermissionToRole(t, model.PermissionManageSharedChannels.Id, th.BasicUser.Roles)
mockSyncService := app.NewMockSharedChannelService(th.Server.GetSharedChannelSyncService())
th.Server.SetSharedChannelSyncService(mockSyncService)
channel := th.CreateChannel(t, th.BasicTeam, WithShared(true))
seedInvitedRemote(t, th, channel.Id)
commandProvider := ShareProvider{}
response := commandProvider.DoCommand(th.App, th.Context, statusArgs(th, channel.Id), "")
require.Contains(t, response.Text, "Remote Status Display")
assert.Contains(t, response.Text, "| -- |", "expected the Last Sync placeholder for an unsynced remote, got:\n%s", response.Text)
assert.NotContains(t, response.Text, "Today ", "unsynced remote should not render a timestamp:\n%s", response.Text)
})
}
func TestShareProviderGetAutoCompleteListItemsPermission(t *testing.T) {
connectionIDArg := func() *model.AutocompleteArg {
return &model.AutocompleteArg{Name: "connectionID"}
}
seedRemote := func(t *testing.T, th *TestHelper) *model.RemoteCluster {
t.Helper()
rc, err := th.App.AddRemoteCluster(&model.RemoteCluster{
RemoteId: model.NewId(),
Name: "remote-" + model.NewId(),
DisplayName: "Remote Display Name Sentinel",
SiteURL: "https://remote-sentinel.example.com",
Token: model.NewId(),
Topics: "topic",
CreateAt: model.GetMillis(),
LastPingAt: model.GetMillis(),
CreatorId: model.NewId(),
})
require.Nil(t, err)
return rc
}
assertNoRemoteData := func(t *testing.T, items []model.AutocompleteListItem, rc *model.RemoteCluster) {
t.Helper()
for i, item := range items {
assert.NotContains(t, item.Item, rc.RemoteId, "item[%d].Item contained RemoteId", i)
assert.NotContains(t, item.HelpText, rc.RemoteId, "item[%d].HelpText contained RemoteId", i)
assert.NotContains(t, item.HelpText, rc.DisplayName, "item[%d].HelpText contained DisplayName", i)
assert.NotContains(t, item.HelpText, rc.SiteURL, "item[%d].HelpText contained SiteURL", i)
}
}
t.Run("invite without manage_shared_channels permission returns no remote cluster data", func(t *testing.T) {
th := setupForSharedChannels(t).initBasic(t)
require.False(t, th.App.HasPermissionTo(th.Context, th.BasicUser.Id, model.PermissionManageSharedChannels),
"precondition: BasicUser must not have manage_shared_channels for this subtest")
rc := seedRemote(t, th)
commandProvider := ShareProvider{}
channel := th.CreateChannel(t, th.BasicTeam, WithShared(false))
args := &model.CommandArgs{
T: func(s string, args ...any) string { return s },
ChannelId: channel.Id,
UserId: th.BasicUser.Id,
TeamId: th.BasicTeam.Id,
Command: "/share-channel invite --connectionID",
}
items, err := commandProvider.GetAutoCompleteListItems(th.Context, th.App, args, connectionIDArg(), "/share-channel invite ", "")
if err == nil {
assert.Empty(t, items, "expected empty autocomplete list when caller lacks manage_shared_channels")
}
assertNoRemoteData(t, items, rc)
})
t.Run("uninvite without manage_shared_channels permission returns no remote cluster data", func(t *testing.T) {
th := setupForSharedChannels(t).initBasic(t)
require.False(t, th.App.HasPermissionTo(th.Context, th.BasicUser.Id, model.PermissionManageSharedChannels),
"precondition: BasicUser must not have manage_shared_channels for this subtest")
rc := seedRemote(t, th)
commandProvider := ShareProvider{}
channel := th.CreateChannel(t, th.BasicTeam, WithShared(false))
args := &model.CommandArgs{
T: func(s string, args ...any) string { return s },
ChannelId: channel.Id,
UserId: th.BasicUser.Id,
TeamId: th.BasicTeam.Id,
Command: "/share-channel uninvite --connectionID",
}
items, err := commandProvider.GetAutoCompleteListItems(th.Context, th.App, args, connectionIDArg(), "/share-channel uninvite ", "")
if err == nil {
assert.Empty(t, items, "expected empty autocomplete list when caller lacks manage_shared_channels")
}
assertNoRemoteData(t, items, rc)
})
t.Run("invite with manage_shared_channels permission returns remote cluster data", func(t *testing.T) {
th := setupForSharedChannels(t).initBasic(t)
th.addPermissionToRole(t, model.PermissionManageSharedChannels.Id, th.BasicUser.Roles)
rc := seedRemote(t, th)
commandProvider := ShareProvider{}
channel := th.CreateChannel(t, th.BasicTeam, WithShared(false))
args := &model.CommandArgs{
T: func(s string, args ...any) string { return s },
ChannelId: channel.Id,
UserId: th.BasicUser.Id,
TeamId: th.BasicTeam.Id,
Command: "/share-channel invite --connectionID",
}
items, err := commandProvider.GetAutoCompleteListItems(th.Context, th.App, args, connectionIDArg(), "/share-channel invite ", "")
require.NoError(t, err)
require.NotEmpty(t, items, "expected at least one autocomplete item when caller has manage_shared_channels")
found := false
for _, item := range items {
if item.Item == rc.RemoteId {
found = true
break
}
}
require.True(t, found, "expected seeded RemoteId %q to appear in autocomplete items when caller has manage_shared_channels", rc.RemoteId)
})
t.Run("uninvite with manage_shared_channels permission returns remote cluster data", func(t *testing.T) {
th := setupForSharedChannels(t).initBasic(t)
th.addPermissionToRole(t, model.PermissionManageSharedChannels.Id, th.BasicUser.Roles)
rc := seedRemote(t, th)
commandProvider := ShareProvider{}
channel := th.CreateChannel(t, th.BasicTeam, WithShared(false))
args := &model.CommandArgs{
T: func(s string, args ...any) string { return s },
ChannelId: channel.Id,
UserId: th.BasicUser.Id,
TeamId: th.BasicTeam.Id,
Command: "/share-channel uninvite --connectionID",
}
items, err := commandProvider.GetAutoCompleteListItems(th.Context, th.App, args, connectionIDArg(), "/share-channel uninvite ", "")
require.NoError(t, err)
require.NotEmpty(t, items, "expected at least one autocomplete item when caller has manage_shared_channels")
found := false
for _, item := range items {
if item.Item == rc.RemoteId {
found = true
break
}
}
require.True(t, found, "expected seeded RemoteId %q to appear in autocomplete items when caller has manage_shared_channels", rc.RemoteId)
})
}
func TestShareProviderGetAutoCompleteListItemsAdjacentRoles(t *testing.T) {
connectionIDArg := func() *model.AutocompleteArg {
return &model.AutocompleteArg{Name: "connectionID"}
}
seedRemote := func(t *testing.T, th *TestHelper) *model.RemoteCluster {
t.Helper()
rc, err := th.App.AddRemoteCluster(&model.RemoteCluster{
RemoteId: model.NewId(),
Name: "remote-" + model.NewId(),
DisplayName: "Adjacent Sentinel Display",
SiteURL: "https://adjacent-sentinel.example.com",
Token: model.NewId(),
Topics: "topic",
CreateAt: model.GetMillis(),
LastPingAt: model.GetMillis(),
CreatorId: model.NewId(),
})
require.Nil(t, err)
return rc
}
assertNoRemoteData := func(t *testing.T, items []model.AutocompleteListItem, rc *model.RemoteCluster) {
t.Helper()
for i, item := range items {
assert.NotContains(t, item.Item, rc.RemoteId, "item[%d].Item contained RemoteId", i)
assert.NotContains(t, item.HelpText, rc.RemoteId, "item[%d].HelpText contained RemoteId", i)
assert.NotContains(t, item.HelpText, rc.DisplayName, "item[%d].HelpText contained DisplayName", i)
assert.NotContains(t, item.HelpText, rc.SiteURL, "item[%d].HelpText contained SiteURL", i)
}
}
t.Run("guest user receives no remote cluster data on invite autocomplete", func(t *testing.T) {
th := setupForSharedChannels(t).initBasic(t)
guest := th.createGuest(t)
require.False(t, th.App.HasPermissionTo(th.Context, guest.Id, model.PermissionManageSharedChannels),
"precondition: a freshly-created guest must not have manage_shared_channels")
rc := seedRemote(t, th)
commandProvider := ShareProvider{}
channel := th.CreateChannel(t, th.BasicTeam, WithShared(false))
args := &model.CommandArgs{
T: func(s string, args ...any) string { return s },
ChannelId: channel.Id,
UserId: guest.Id,
TeamId: th.BasicTeam.Id,
Command: "/share-channel invite --connectionID",
}
items, err := commandProvider.GetAutoCompleteListItems(th.Context, th.App, args, connectionIDArg(), "/share-channel invite ", "")
if err == nil {
assert.Empty(t, items, "expected empty autocomplete list for guest on invite")
}
assertNoRemoteData(t, items, rc)
})
t.Run("guest user receives no remote cluster data on uninvite autocomplete", func(t *testing.T) {
th := setupForSharedChannels(t).initBasic(t)
guest := th.createGuest(t)
require.False(t, th.App.HasPermissionTo(th.Context, guest.Id, model.PermissionManageSharedChannels),
"precondition: a freshly-created guest must not have manage_shared_channels")
rc := seedRemote(t, th)
commandProvider := ShareProvider{}
channel := th.CreateChannel(t, th.BasicTeam, WithShared(false))
args := &model.CommandArgs{
T: func(s string, args ...any) string { return s },
ChannelId: channel.Id,
UserId: guest.Id,
TeamId: th.BasicTeam.Id,
Command: "/share-channel uninvite --connectionID",
}
items, err := commandProvider.GetAutoCompleteListItems(th.Context, th.App, args, connectionIDArg(), "/share-channel uninvite ", "")
if err == nil {
assert.Empty(t, items, "expected empty autocomplete list for guest on uninvite")
}
assertNoRemoteData(t, items, rc)
})
t.Run("system admin receives remote cluster data on invite", func(t *testing.T) {
th := setupForSharedChannels(t).initBasic(t)
require.True(t, th.App.HasPermissionTo(th.Context, th.SystemAdminUser.Id, model.PermissionManageSharedChannels),
"precondition: SystemAdminUser must have manage_shared_channels via inherited permissions")
rc := seedRemote(t, th)
commandProvider := ShareProvider{}
channel := th.CreateChannel(t, th.BasicTeam, WithShared(false))
args := &model.CommandArgs{
T: func(s string, args ...any) string { return s },
ChannelId: channel.Id,
UserId: th.SystemAdminUser.Id,
TeamId: th.BasicTeam.Id,
Command: "/share-channel invite --connectionID",
}
items, err := commandProvider.GetAutoCompleteListItems(th.Context, th.App, args, connectionIDArg(), "/share-channel invite ", "")
require.NoError(t, err)
require.NotEmpty(t, items, "expected at least one autocomplete item for system admin")
found := false
for _, item := range items {
if item.Item == rc.RemoteId {
found = true
break
}
}
require.True(t, found, "expected seeded RemoteId %q to appear for system admin", rc.RemoteId)
})
t.Run("system admin receives remote cluster data on uninvite", func(t *testing.T) {
th := setupForSharedChannels(t).initBasic(t)
require.True(t, th.App.HasPermissionTo(th.Context, th.SystemAdminUser.Id, model.PermissionManageSharedChannels),
"precondition: SystemAdminUser must have manage_shared_channels via inherited permissions")
rc := seedRemote(t, th)
commandProvider := ShareProvider{}
channel := th.CreateChannel(t, th.BasicTeam, WithShared(false))
args := &model.CommandArgs{
T: func(s string, args ...any) string { return s },
ChannelId: channel.Id,
UserId: th.SystemAdminUser.Id,
TeamId: th.BasicTeam.Id,
Command: "/share-channel uninvite --connectionID",
}
items, err := commandProvider.GetAutoCompleteListItems(th.Context, th.App, args, connectionIDArg(), "/share-channel uninvite ", "")
require.NoError(t, err)
require.NotEmpty(t, items, "expected at least one autocomplete item for system admin")
found := false
for _, item := range items {
if item.Item == rc.RemoteId {
found = true
break
}
}
require.True(t, found, "expected seeded RemoteId %q to appear for system admin", rc.RemoteId)
})
}

View File

@@ -0,0 +1,44 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package slashcommands
import (
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/i18n"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/app"
)
type ShortcutsProvider struct {
}
const (
CmdShortcuts = "shortcuts"
)
func init() {
app.RegisterCommandProvider(&ShortcutsProvider{})
}
func (*ShortcutsProvider) GetTrigger() string {
return CmdShortcuts
}
func (*ShortcutsProvider) GetCommand(a *app.App, T i18n.TranslateFunc) *model.Command {
return &model.Command{
Trigger: CmdShortcuts,
AutoComplete: true,
AutoCompleteDesc: T("api.command_shortcuts.desc"),
AutoCompleteHint: "",
DisplayName: T("api.command_shortcuts.name"),
}
}
func (*ShortcutsProvider) DoCommand(a *app.App, rctx request.CTX, args *model.CommandArgs, message string) *model.CommandResponse {
// This command is handled client-side and shouldn't hit the server.
return &model.CommandResponse{
Text: args.T("api.command_shortcuts.unsupported.app_error"),
ResponseType: model.CommandResponseTypeEphemeral,
}
}

View File

@@ -0,0 +1,45 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package slashcommands
import (
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/i18n"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/app"
)
type ShrugProvider struct {
}
const (
CmdShrug = "shrug"
)
func init() {
app.RegisterCommandProvider(&ShrugProvider{})
}
func (*ShrugProvider) GetTrigger() string {
return CmdShrug
}
func (*ShrugProvider) GetCommand(a *app.App, T i18n.TranslateFunc) *model.Command {
return &model.Command{
Trigger: CmdShrug,
AutoComplete: true,
AutoCompleteDesc: T("api.command_shrug.desc"),
AutoCompleteHint: T("api.command_shrug.hint"),
DisplayName: T("api.command_shrug.name"),
}
}
func (*ShrugProvider) DoCommand(a *app.App, rctx request.CTX, args *model.CommandArgs, message string) *model.CommandResponse {
rmsg := `¯\\\_(ツ)\_/¯`
if message != "" {
rmsg = message + " " + rmsg
}
return &model.CommandResponse{ResponseType: model.CommandResponseTypeInChannel, Text: rmsg}
}

View File

@@ -0,0 +1,770 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package slashcommands
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/plugin/plugintest/mock"
"github.com/mattermost/mattermost/server/v8/einterfaces/mocks"
)
type InfiniteReader struct{}
func (r InfiniteReader) Read(p []byte) (n int, err error) {
for i := range p {
p[i] = 'a'
}
return len(p), nil
}
func TestMoveCommand(t *testing.T) {
th := setup(t)
sourceTeam := th.createTeam(t)
targetTeam := th.createTeam(t)
t.Cleanup(func() {
appErr := th.App.PermanentDeleteTeam(th.Context, sourceTeam)
require.Nil(t, appErr)
appErr = th.App.PermanentDeleteTeam(th.Context, targetTeam)
require.Nil(t, appErr)
})
command := &model.Command{}
command.CreatorId = model.NewId()
command.Method = model.CommandMethodPost
command.TeamId = sourceTeam.Id
command.URL = "http://nowhere.com/"
command.Trigger = "trigger1"
command, err := th.App.CreateCommand(command)
assert.Nil(t, err)
// Move a command and check the team is updated.
assert.Nil(t, th.App.MoveCommand(targetTeam, command))
retrievedCommand, err := th.App.GetCommand(command.Id)
assert.Nil(t, err)
assert.EqualValues(t, targetTeam.Id, retrievedCommand.TeamId)
// Move it to the team it's already in. Nothing should change.
assert.Nil(t, th.App.MoveCommand(targetTeam, command))
retrievedCommand, err = th.App.GetCommand(command.Id)
assert.Nil(t, err)
assert.EqualValues(t, targetTeam.Id, retrievedCommand.TeamId)
// Move a command to a team where the trigger already exists should fail.
command2 := &model.Command{}
command2.CreatorId = model.NewId()
command2.Method = model.CommandMethodPost
command2.TeamId = sourceTeam.Id
command2.URL = "http://nowhere.com/"
command2.Trigger = "trigger1"
command2, err = th.App.CreateCommand(command2)
assert.Nil(t, err)
moveErr := th.App.MoveCommand(targetTeam, command2)
assert.NotNil(t, moveErr)
assert.Equal(t, "api.command.duplicate_trigger.app_error", moveErr.Id)
}
func TestCreateCommandPost(t *testing.T) {
th := setup(t).initBasic(t)
post := &model.Post{
ChannelId: th.BasicChannel.Id,
UserId: th.BasicUser.Id,
Type: model.PostTypeSystemGeneric,
}
resp := &model.CommandResponse{
Text: "some message",
}
skipSlackParsing := false
_, err := th.App.CreateCommandPost(th.Context, post, th.BasicTeam.Id, resp, skipSlackParsing)
require.NotNil(t, err)
require.Equal(t, err.Id, "api.context.invalid_param.app_error")
}
func TestExecuteCommand(t *testing.T) {
th := setup(t).initBasic(t)
t.Run("valid tests with different whitespace characters", func(t *testing.T) {
TestCases := map[string]string{
"/code happy path": " happy path",
"/code\nnewline path": " newline path",
"/code\n/nDouble newline path": " /nDouble newline path",
"/code double space": " double space",
"/code\ttab": " tab",
}
for TestCase, result := range TestCases {
args := &model.CommandArgs{
Command: TestCase,
TeamId: th.BasicTeam.Id,
ChannelId: th.BasicChannel.Id,
UserId: th.BasicUser.Id,
T: func(s string, args ...any) string { return s },
}
resp, err := th.App.ExecuteCommand(th.Context, args)
require.Nil(t, err)
require.NotNil(t, resp)
assert.Equal(t, resp.Text, result)
}
})
t.Run("missing slash character", func(t *testing.T) {
argsMissingSlashCharacter := &model.CommandArgs{
Command: "missing leading slash character",
T: func(s string, args ...any) string { return s },
}
_, err := th.App.ExecuteCommand(th.Context, argsMissingSlashCharacter)
require.Equal(t, "api.command.execute_command.format.app_error", err.Id)
})
t.Run("empty", func(t *testing.T) {
argsMissingSlashCharacter := &model.CommandArgs{
Command: "",
T: func(s string, args ...any) string { return s },
}
_, err := th.App.ExecuteCommand(th.Context, argsMissingSlashCharacter)
require.Equal(t, "api.command.execute_command.format.app_error", err.Id)
})
}
func TestHandleCommandResponsePost(t *testing.T) {
th := setup(t).initBasic(t)
command := &model.Command{}
args := &model.CommandArgs{
ChannelId: th.BasicChannel.Id,
TeamId: th.BasicTeam.Id,
UserId: th.BasicUser.Id,
RootId: "",
}
resp := &model.CommandResponse{
Type: model.PostTypeDefault,
ResponseType: model.CommandResponseTypeInChannel,
Props: model.StringInterface{"some_key": "some value"},
Text: "some message",
}
builtIn := true
post, err := th.App.HandleCommandResponsePost(th.Context, command, args, resp, builtIn)
assert.Nil(t, err)
assert.Equal(t, args.ChannelId, post.ChannelId)
assert.Equal(t, args.RootId, post.RootId)
assert.Equal(t, args.UserId, post.UserId)
assert.Equal(t, resp.Type, post.Type)
assert.Equal(t, resp.Props, post.GetProps())
assert.Equal(t, resp.Text, post.Message)
assert.Nil(t, post.GetProp(model.PostPropsOverrideIconURL))
assert.Nil(t, post.GetProp(model.PostPropsOverrideUsername))
assert.Nil(t, post.GetProp(model.PostPropsFromWebhook))
// Command is not built in, so it is a bot command.
builtIn = false
post, err = th.App.HandleCommandResponsePost(th.Context, command, args, resp, builtIn)
assert.Nil(t, err)
assert.Equal(t, "true", post.GetProp(model.PostPropsFromWebhook))
builtIn = true
// Channel id is specified by response, it should override the command args value.
channel := th.CreateChannel(t, th.BasicTeam)
resp.ChannelId = channel.Id
th.addUserToChannel(t, th.BasicUser, channel)
post, err = th.App.HandleCommandResponsePost(th.Context, command, args, resp, builtIn)
assert.Nil(t, err)
assert.Equal(t, resp.ChannelId, post.ChannelId)
assert.NotEqual(t, args.ChannelId, post.ChannelId)
// Override username config is turned off. No override should occur.
*th.App.Config().ServiceSettings.EnablePostUsernameOverride = false
resp.ChannelId = ""
command.Username = "Command username"
resp.Username = "Response username"
post, err = th.App.HandleCommandResponsePost(th.Context, command, args, resp, builtIn)
assert.Nil(t, err)
assert.Nil(t, post.GetProp(model.PostPropsOverrideUsername))
*th.App.Config().ServiceSettings.EnablePostUsernameOverride = true
// Override username config is turned on. Override username through command property.
post, err = th.App.HandleCommandResponsePost(th.Context, command, args, resp, builtIn)
assert.Nil(t, err)
assert.Equal(t, command.Username, post.GetProp(model.PostPropsOverrideUsername))
assert.Equal(t, "true", post.GetProp(model.PostPropsFromWebhook))
command.Username = ""
// Override username through response property.
post, err = th.App.HandleCommandResponsePost(th.Context, command, args, resp, builtIn)
assert.Nil(t, err)
assert.Equal(t, resp.Username, post.GetProp(model.PostPropsOverrideUsername))
assert.Equal(t, "true", post.GetProp(model.PostPropsFromWebhook))
*th.App.Config().ServiceSettings.EnablePostUsernameOverride = false
// Override icon url config is turned off. No override should occur.
*th.App.Config().ServiceSettings.EnablePostIconOverride = false
command.IconURL = "Command icon url"
resp.IconURL = "Response icon url"
post, err = th.App.HandleCommandResponsePost(th.Context, command, args, resp, builtIn)
assert.Nil(t, err)
assert.Nil(t, post.GetProp(model.PostPropsOverrideIconURL))
*th.App.Config().ServiceSettings.EnablePostIconOverride = true
// Override icon url config is turned on. Override icon url through command property.
post, err = th.App.HandleCommandResponsePost(th.Context, command, args, resp, builtIn)
assert.Nil(t, err)
assert.Equal(t, command.IconURL, post.GetProp(model.PostPropsOverrideIconURL))
assert.Equal(t, "true", post.GetProp(model.PostPropsFromWebhook))
command.IconURL = ""
// Override icon url through response property.
post, err = th.App.HandleCommandResponsePost(th.Context, command, args, resp, builtIn)
assert.Nil(t, err)
assert.Equal(t, resp.IconURL, post.GetProp(model.PostPropsOverrideIconURL))
assert.Equal(t, "true", post.GetProp(model.PostPropsFromWebhook))
resp.IconURL = ""
// When both command and response icon URLs are empty and EnablePostIconOverride is enabled,
// override_icon_url must not be set (avoids spurious "prop must be a valid URL" warning).
post, err = th.App.HandleCommandResponsePost(th.Context, command, args, resp, builtIn)
assert.Nil(t, err)
assert.Nil(t, post.GetProp(model.PostPropsOverrideIconURL))
assert.NotContains(t, post.GetProps(), model.PostPropsOverrideIconURL)
resp.IconURL = "Response icon url"
// Test Slack text conversion.
resp.Text = "<!channel>"
post, err = th.App.HandleCommandResponsePost(th.Context, command, args, resp, builtIn)
assert.Nil(t, err)
assert.Equal(t, "@channel", post.Message)
assert.Equal(t, "true", post.GetProp(model.PostPropsFromWebhook))
// Test Slack attachments text conversion.
resp.Attachments = []*model.MessageAttachment{
{
Text: "<!here>",
},
}
post, err = th.App.HandleCommandResponsePost(th.Context, command, args, resp, builtIn)
assert.Nil(t, err)
assert.Equal(t, "@channel", post.Message)
if assert.Len(t, post.Attachments(), 1) {
assert.Equal(t, "@here", post.Attachments()[0].Text)
}
assert.Equal(t, "true", post.GetProp(model.PostPropsFromWebhook))
channel = th.createPrivateChannel(t, th.BasicTeam)
resp.ChannelId = channel.Id
args.UserId = th.BasicUser2.Id
_, err = th.App.HandleCommandResponsePost(th.Context, command, args, resp, builtIn)
require.NotNil(t, err)
require.Equal(t, err.Id, "api.command.command_post.forbidden.app_error")
// Test that /code text is not converted with the Slack text conversion.
command.Trigger = "code"
resp.ChannelId = ""
resp.Text = "<test.com|test website>"
resp.Attachments = []*model.MessageAttachment{
{
Text: "<!here>",
},
}
// set and unset SkipSlackParsing here seems the nicest way as no separate response objects are created for every testcase.
resp.SkipSlackParsing = true
post, err = th.App.HandleCommandResponsePost(th.Context, command, args, resp, builtIn)
resp.SkipSlackParsing = false
assert.Nil(t, err)
assert.Equal(t, resp.Text, post.Message, "/code text should not be converted to Slack links")
assert.Equal(t, "<!here>", resp.Attachments[0].Text)
}
// response.Props can carry override_* directly (in addition to response.Username /
// response.IconURL). CreateCommandPost must apply the same EnablePost*Override
// gates as the top-level fields so props cannot bypass admin intent.
func TestHandleCommandResponsePostPropsOverrides(t *testing.T) {
th := setup(t).initBasic(t)
command := &model.Command{}
args := &model.CommandArgs{
ChannelId: th.BasicChannel.Id,
TeamId: th.BasicTeam.Id,
UserId: th.BasicUser.Id,
}
// Non-builtin so from_webhook is set and CreatePost reinjects under
// FromIncomingWebhook authority.
builtIn := false
t.Run("props overrides stripped when EnablePost*Override off", func(t *testing.T) {
*th.App.Config().ServiceSettings.EnablePostUsernameOverride = false
*th.App.Config().ServiceSettings.EnablePostIconOverride = false
resp := &model.CommandResponse{
Type: model.PostTypeDefault,
ResponseType: model.CommandResponseTypeInChannel,
Text: "props override attempt",
Props: model.StringInterface{
model.PostPropsOverrideUsername: "PropsUser",
model.PostPropsOverrideIconURL: "http://example.com/icon.png",
model.PostPropsOverrideIconEmoji: ":robot:",
"custom_key": "kept",
},
}
post, err := th.App.HandleCommandResponsePost(th.Context, command, args, resp, builtIn)
require.Nil(t, err)
assert.Equal(t, "true", post.GetProp(model.PostPropsFromWebhook))
assert.Nil(t, post.GetProp(model.PostPropsOverrideUsername))
assert.Nil(t, post.GetProp(model.PostPropsOverrideIconURL))
assert.Nil(t, post.GetProp(model.PostPropsOverrideIconEmoji))
assert.Equal(t, "kept", post.GetProp("custom_key"))
})
t.Run("props overrides reinjected when EnablePost*Override on", func(t *testing.T) {
*th.App.Config().ServiceSettings.EnablePostUsernameOverride = true
*th.App.Config().ServiceSettings.EnablePostIconOverride = true
resp := &model.CommandResponse{
Type: model.PostTypeDefault,
ResponseType: model.CommandResponseTypeInChannel,
Text: "props override allowed",
Props: model.StringInterface{
model.PostPropsOverrideUsername: "PropsUser",
model.PostPropsOverrideIconURL: "http://example.com/icon.png",
model.PostPropsOverrideIconEmoji: ":robot:",
},
}
post, err := th.App.HandleCommandResponsePost(th.Context, command, args, resp, builtIn)
require.Nil(t, err)
assert.Equal(t, "true", post.GetProp(model.PostPropsFromWebhook))
assert.Equal(t, "PropsUser", post.GetProp(model.PostPropsOverrideUsername))
assert.Equal(t, "http://example.com/icon.png", post.GetProp(model.PostPropsOverrideIconURL))
assert.Equal(t, ":robot:", post.GetProp(model.PostPropsOverrideIconEmoji))
})
}
func TestHandleCommandResponse(t *testing.T) {
th := setup(t).initBasic(t)
command := &model.Command{}
args := &model.CommandArgs{
Command: "/invite username",
UserId: th.BasicUser.Id,
ChannelId: th.BasicChannel.Id,
}
resp := &model.CommandResponse{
Text: "message 1",
Type: model.PostTypeSystemGeneric,
}
builtIn := true
_, err := th.App.HandleCommandResponse(th.Context, command, args, resp, builtIn)
require.NotNil(t, err)
require.Equal(t, err.Id, "api.command.execute_command.create_post_failed.app_error")
resp = &model.CommandResponse{
Text: "message 1",
}
_, err = th.App.HandleCommandResponse(th.Context, command, args, resp, builtIn)
assert.Nil(t, err)
resp = &model.CommandResponse{
Text: "message 1",
ExtraResponses: []*model.CommandResponse{
{
Text: "message 2",
},
{
Type: model.PostTypeSystemGeneric,
Text: "message 3",
},
},
}
_, err = th.App.HandleCommandResponse(th.Context, command, args, resp, builtIn)
require.NotNil(t, err)
require.Equal(t, err.Id, "api.command.execute_command.create_post_failed.app_error")
resp = &model.CommandResponse{
ExtraResponses: []*model.CommandResponse{
{},
{},
},
}
_, err = th.App.HandleCommandResponse(th.Context, command, args, resp, builtIn)
assert.Nil(t, err)
}
func TestDoCommandRequest(t *testing.T) {
th := setup(t)
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.ServiceSettings.AllowedUntrustedInternalConnections = new("127.0.0.1")
cfg.ServiceSettings.EnableCommands = new(true)
})
t.Run("with a valid text response", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, err := io.Copy(w, strings.NewReader("Hello, World!"))
require.NoError(t, err)
}))
t.Cleanup(server.Close)
_, resp, err := th.App.DoCommandRequest(th.Context, &model.Command{URL: server.URL}, url.Values{})
require.Nil(t, err)
assert.NotNil(t, resp)
assert.Equal(t, "Hello, World!", resp.Text)
})
t.Run("with a valid json response", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Content-Type", "application/json")
_, err := io.Copy(w, strings.NewReader(`{"text": "Hello, World!"}`))
require.NoError(t, err)
}))
t.Cleanup(server.Close)
_, resp, err := th.App.DoCommandRequest(th.Context, &model.Command{URL: server.URL}, url.Values{})
require.Nil(t, err)
assert.NotNil(t, resp)
assert.Equal(t, "Hello, World!", resp.Text)
})
t.Run("with a large text response", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, err := io.Copy(w, InfiniteReader{})
require.Error(t, err) // InfiniteReader never returns EOF, so this will error
}))
t.Cleanup(server.Close)
// Since we limit the length of the response, no error will be returned and resp.Text will be a finite string
_, resp, err := th.App.DoCommandRequest(th.Context, &model.Command{URL: server.URL}, url.Values{})
require.Nil(t, err)
require.NotNil(t, resp)
})
t.Run("with a large, valid json response", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Content-Type", "application/json")
_, err := io.Copy(w, io.MultiReader(strings.NewReader(`{"text": "`), InfiniteReader{}, strings.NewReader(`"}`)))
require.Error(t, err) // InfiniteReader never returns EOF, so this will error
}))
t.Cleanup(server.Close)
_, _, err := th.App.DoCommandRequest(th.Context, &model.Command{URL: server.URL}, url.Values{})
require.NotNil(t, err)
require.Equal(t, "api.command.execute_command.failed.app_error", err.Id)
})
t.Run("with a large, invalid json response", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Content-Type", "application/json")
_, err := io.Copy(w, InfiniteReader{})
require.Error(t, err) // InfiniteReader never returns EOF, so this will error
}))
t.Cleanup(server.Close)
_, _, err := th.App.DoCommandRequest(th.Context, &model.Command{URL: server.URL}, url.Values{})
require.NotNil(t, err)
require.Equal(t, "api.command.execute_command.failed.app_error", err.Id)
})
t.Run("with a too slow response", func(t *testing.T) {
done := make(chan bool)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
<-done
_, err := io.Copy(w, strings.NewReader("Hello, World!"))
require.NoError(t, err)
}))
t.Cleanup(server.Close)
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.ServiceSettings.OutgoingIntegrationRequestsTimeout = new(int64(1))
})
_, _, err := th.App.DoCommandRequest(th.Context, &model.Command{URL: server.URL}, url.Values{})
require.NotNil(t, err)
require.Equal(t, "api.command.execute_command.failed.app_error", err.Id)
close(done)
})
t.Run("with a too slow response, long timeout configured", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(1 * time.Second)
_, err := io.Copy(w, strings.NewReader("Hello, World!"))
require.NoError(t, err)
}))
t.Cleanup(server.Close)
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.ServiceSettings.OutgoingIntegrationRequestsTimeout = new(int64(2))
})
_, resp, appErr := th.App.DoCommandRequest(th.Context, &model.Command{URL: server.URL}, url.Values{})
require.Nil(t, appErr)
require.NotNil(t, resp)
assert.Equal(t, "Hello, World!", resp.Text)
})
t.Run("with a url that matches an outgoing oauth connection", func(t *testing.T) {
outgoingOauthIface := &mocks.OutgoingOAuthConnectionInterface{}
outgoingOauthImpl := th.App.Srv().OutgoingOAuthConnection
outgoingOAuthConnectionConfig := th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections
th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections = new(true)
t.Cleanup(func() {
th.App.Srv().OutgoingOAuthConnection = outgoingOauthImpl
th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections = outgoingOAuthConnectionConfig
})
th.App.Srv().OutgoingOAuthConnection = outgoingOauthIface
serverCommand := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, err := io.Copy(w, strings.NewReader(r.Header.Get("Authorization")))
require.NoError(t, err)
}))
t.Cleanup(serverCommand.Close)
connection := &model.OutgoingOAuthConnection{
Id: model.NewId(),
Name: "test",
ClientId: "test",
ClientSecret: "test",
CreatorId: model.NewId(),
OAuthTokenURL: "fake",
GrantType: model.OutgoingOAuthConnectionGrantTypeClientCredentials,
Audiences: model.StringArray{
serverCommand.URL,
},
}
outgoingOauthIface.Mock.On("GetConnectionForAudience", mock.Anything, serverCommand.URL).Return(connection, nil)
outgoingOauthIface.Mock.On("SanitizeConnections", mock.Anything)
outgoingOauthIface.Mock.On("RetrieveTokenForConnection", mock.Anything, connection).Return(&model.OutgoingOAuthConnectionToken{
AccessToken: "token",
TokenType: "type",
}, nil)
_, resp, err := th.App.DoCommandRequest(th.Context, &model.Command{URL: serverCommand.URL}, url.Values{})
require.Nil(t, err)
require.NotNil(t, resp)
// Ensure that the Authorization header was set correctly by reading the body from the command response
// which was set to the Authorization header by the command handler.
assert.Equal(t, "type token", resp.Text)
})
}
func TestMentionsToTeamMembers(t *testing.T) {
th := setup(t).initBasic(t)
otherTeam := th.createTeam(t)
otherUser := th.createUser(t)
th.linkUserToTeam(t, otherUser, otherTeam)
fixture := []struct {
message string
inTeam string
expectedMap model.UserMentionMap
}{
{
"",
th.BasicTeam.Id,
model.UserMentionMap{},
},
{
"/trigger",
th.BasicTeam.Id,
model.UserMentionMap{},
},
{
"/trigger 0 mentions",
th.BasicTeam.Id,
model.UserMentionMap{},
},
{
fmt.Sprintf("/trigger 1 valid user @%s", th.BasicUser.Username),
th.BasicTeam.Id,
model.UserMentionMap{th.BasicUser.Username: th.BasicUser.Id},
},
{
fmt.Sprintf("/trigger 2 valid users @%s @%s",
th.BasicUser.Username, th.BasicUser2.Username,
),
th.BasicTeam.Id,
model.UserMentionMap{
th.BasicUser.Username: th.BasicUser.Id,
th.BasicUser2.Username: th.BasicUser2.Id,
},
},
{
fmt.Sprintf("/trigger 1 user from another team @%s", otherUser.Username),
th.BasicTeam.Id,
model.UserMentionMap{},
},
{
fmt.Sprintf("/trigger 2 valid users + 1 from another team @%s @%s @%s",
th.BasicUser.Username, th.BasicUser2.Username, otherUser.Username,
),
th.BasicTeam.Id,
model.UserMentionMap{
th.BasicUser.Username: th.BasicUser.Id,
th.BasicUser2.Username: th.BasicUser2.Id,
},
},
{
fmt.Sprintf("/trigger a valid channel ~%s", th.BasicChannel.Name),
th.BasicTeam.Id,
model.UserMentionMap{},
},
{
fmt.Sprintf("/trigger channel and mentions ~%s @%s",
th.BasicChannel.Name, th.BasicUser.Username),
th.BasicTeam.Id,
model.UserMentionMap{th.BasicUser.Username: th.BasicUser.Id},
},
{
fmt.Sprintf("/trigger repeated users @%s @%s @%s",
th.BasicUser.Username, th.BasicUser2.Username, th.BasicUser.Username),
th.BasicTeam.Id,
model.UserMentionMap{
th.BasicUser.Username: th.BasicUser.Id,
th.BasicUser2.Username: th.BasicUser2.Id,
},
},
}
for _, data := range fixture {
actualMap := th.App.MentionsToTeamMembers(th.Context, data.message, data.inTeam)
require.Equal(t, actualMap, data.expectedMap)
}
}
func TestMentionsToPublicChannels(t *testing.T) {
th := setup(t).initBasic(t)
otherPublicChannel := th.CreateChannel(t, th.BasicTeam)
privateChannel := th.createPrivateChannel(t, th.BasicTeam)
fixture := []struct {
message string
inTeam string
expectedMap model.ChannelMentionMap
}{
{
"",
th.BasicTeam.Id,
model.ChannelMentionMap{},
},
{
"/trigger",
th.BasicTeam.Id,
model.ChannelMentionMap{},
},
{
"/trigger 0 mentions",
th.BasicTeam.Id,
model.ChannelMentionMap{},
},
{
fmt.Sprintf("/trigger 1 public channel ~%s", th.BasicChannel.Name),
th.BasicTeam.Id,
model.ChannelMentionMap{th.BasicChannel.Name: th.BasicChannel.Id},
},
{
fmt.Sprintf("/trigger 2 public channels ~%s ~%s",
th.BasicChannel.Name, otherPublicChannel.Name,
),
th.BasicTeam.Id,
model.ChannelMentionMap{
th.BasicChannel.Name: th.BasicChannel.Id,
otherPublicChannel.Name: otherPublicChannel.Id,
},
},
{
fmt.Sprintf("/trigger 1 private channel ~%s", privateChannel.Name),
th.BasicTeam.Id,
model.ChannelMentionMap{},
},
{
fmt.Sprintf("/trigger 2 public channel + 1 private ~%s ~%s ~%s",
th.BasicChannel.Name, otherPublicChannel.Name, privateChannel.Name,
),
th.BasicTeam.Id,
model.ChannelMentionMap{
th.BasicChannel.Name: th.BasicChannel.Id,
otherPublicChannel.Name: otherPublicChannel.Id,
},
},
{
fmt.Sprintf("/trigger a valid user @%s", th.BasicUser.Username),
th.BasicTeam.Id,
model.ChannelMentionMap{},
},
{
fmt.Sprintf("/trigger channel and mentions ~%s @%s",
th.BasicChannel.Name, th.BasicUser.Username),
th.BasicTeam.Id,
model.ChannelMentionMap{th.BasicChannel.Name: th.BasicChannel.Id},
},
{
fmt.Sprintf("/trigger repeated channels ~%s ~%s ~%s",
th.BasicChannel.Name, otherPublicChannel.Name, th.BasicChannel.Name),
th.BasicTeam.Id,
model.ChannelMentionMap{
th.BasicChannel.Name: th.BasicChannel.Id,
otherPublicChannel.Name: otherPublicChannel.Id,
},
},
}
for _, data := range fixture {
actualMap := th.App.MentionsToPublicChannels(th.Context, data.message, data.inTeam)
require.Equal(t, actualMap, data.expectedMap)
}
}

View File

@@ -0,0 +1,396 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package slashcommands
import (
"bytes"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/app"
"github.com/mattermost/mattermost/server/v8/channels/store"
"github.com/mattermost/mattermost/server/v8/config"
)
type TestHelper struct {
App *app.App
Context *request.Context
Server *app.Server
BasicTeam *model.Team
BasicUser *model.User
BasicUser2 *model.User
BasicChannel *model.Channel
BasicPost *model.Post
SystemAdminUser *model.User
LogBuffer *bytes.Buffer
TestLogger *mlog.Logger
IncludeCacheLayer bool
tempWorkspace string
}
func setupTestHelper(dbStore store.Store, enterprise bool, includeCacheLayer bool, tb testing.TB, configSet func(*model.Config)) *TestHelper {
tempWorkspace, err := os.MkdirTemp("", "apptest")
require.NoError(tb, err)
memoryStore := config.NewTestMemoryStore()
memoryConfig := memoryStore.Get()
if configSet != nil {
configSet(memoryConfig)
}
memoryConfig.SqlSettings = *mainHelper.GetSQLSettings()
*memoryConfig.PluginSettings.Directory = filepath.Join(tempWorkspace, "plugins")
*memoryConfig.PluginSettings.ClientDirectory = filepath.Join(tempWorkspace, "webapp")
*memoryConfig.PluginSettings.AutomaticPrepackagedPlugins = false
*memoryConfig.LogSettings.EnableSentry = false // disable error reporting during tests
// Check for environment variable override for console log level (useful for debugging tests)
consoleLevel := os.Getenv("MM_LOGSETTINGS_CONSOLELEVEL")
if consoleLevel == "" {
consoleLevel = mlog.LvlStdLog.Name
}
*memoryConfig.LogSettings.ConsoleLevel = consoleLevel
_, _, err = memoryStore.Set(memoryConfig)
require.NoError(tb, err)
buffer := &bytes.Buffer{}
var options []app.Option
options = append(options, app.ConfigStore(memoryStore))
if includeCacheLayer {
options = append(options, app.StoreOverrideWithCache(dbStore))
} else {
options = append(options, app.StoreOverride(dbStore))
}
testLogger, err := mlog.NewLogger()
require.NoError(tb, err)
logCfg, err := config.MloggerConfigFromLoggerConfig(&memoryConfig.LogSettings, nil, config.GetLogFileLocation)
require.NoError(tb, err)
errCfg := testLogger.ConfigureTargets(logCfg, nil)
require.NoError(tb, errCfg, "failed to configure test logger")
// lock logger config so server init cannot override it during testing.
testLogger.LockConfiguration()
options = append(options, app.SetLogger(testLogger))
s, err := app.NewServer(options...)
require.NoError(tb, err)
th := &TestHelper{
App: app.New(app.ServerConnector(s.Channels())),
Context: request.EmptyContext(testLogger),
Server: s,
LogBuffer: buffer,
TestLogger: testLogger,
IncludeCacheLayer: includeCacheLayer,
tempWorkspace: tempWorkspace,
}
if enterprise {
stopErr := th.App.Srv().Jobs.StopWorkers()
require.NoError(tb, stopErr)
stopErr = th.App.Srv().Jobs.StopSchedulers()
require.NoError(tb, stopErr)
th.App.Srv().SetLicense(model.NewTestLicense())
startErr := th.App.Srv().Jobs.StartWorkers()
require.NoError(tb, startErr)
startErr = th.App.Srv().Jobs.StartSchedulers()
require.NoError(tb, startErr)
} else {
th.App.Srv().SetLicense(getLicense(false, memoryConfig))
}
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.TeamSettings.MaxUsersPerTeam = 50 })
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.RateLimitSettings.Enable = false })
prevListenAddress := *th.App.Config().ServiceSettings.ListenAddress
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.ListenAddress = "localhost:0" })
serverErr := th.Server.Start()
require.NoError(tb, serverErr)
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.ListenAddress = prevListenAddress })
th.App.Srv().Platform().SearchEngine = mainHelper.SearchEngine
th.App.Srv().Store().MarkSystemRanUnitTests()
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.TeamSettings.EnableOpenServer = true })
tb.Cleanup(func() {
if th.IncludeCacheLayer {
// Clean all the caches
appErr := th.App.Srv().InvalidateAllCaches()
require.Nil(tb, appErr)
}
th.ShutdownApp()
if th.tempWorkspace != "" {
os.RemoveAll(th.tempWorkspace)
}
})
return th
}
func (th *TestHelper) ShutdownApp() {
done := make(chan bool)
go func() {
th.Server.Shutdown()
close(done)
}()
select {
case <-done:
case <-time.After(30 * time.Second):
// panic instead of fatal to terminate all tests in this package, otherwise the
// still running App could spuriously fail subsequent tests.
panic("failed to shutdown App within 30 seconds")
}
}
func getLicense(enterprise bool, cfg *model.Config) *model.License {
if *cfg.ConnectedWorkspacesSettings.EnableRemoteClusterService || *cfg.ConnectedWorkspacesSettings.EnableSharedChannels {
return model.NewTestLicenseSKU(model.LicenseShortSkuProfessional)
}
if enterprise {
return model.NewTestLicense()
}
return nil
}
func setup(tb testing.TB) *TestHelper {
if testing.Short() {
tb.SkipNow()
}
dbStore := mainHelper.GetStore()
dbStore.DropAllTables()
dbStore.MarkSystemRanUnitTests()
return setupTestHelper(dbStore, false, true, tb, nil)
}
func setupConfig(tb testing.TB, updateConfig func(cfg *model.Config)) *TestHelper {
if testing.Short() {
tb.SkipNow()
}
dbStore := mainHelper.GetStore()
dbStore.DropAllTables()
dbStore.MarkSystemRanUnitTests()
return setupTestHelper(dbStore, false, true, tb, updateConfig)
}
func (th *TestHelper) initBasic(tb testing.TB) *TestHelper {
th.SystemAdminUser = th.createUser(tb)
_, appErr := th.App.UpdateUserRoles(th.Context, th.SystemAdminUser.Id, model.SystemUserRoleId+" "+model.SystemAdminRoleId, false)
require.Nil(tb, appErr)
th.BasicUser = th.createUser(tb)
th.BasicUser2 = th.createUser(tb)
th.BasicTeam = th.createTeam(tb)
th.linkUserToTeam(tb, th.BasicUser, th.BasicTeam)
th.linkUserToTeam(tb, th.BasicUser2, th.BasicTeam)
th.BasicChannel = th.CreateChannel(tb, th.BasicTeam)
th.BasicPost = th.createPost(tb, th.BasicChannel)
return th
}
func (th *TestHelper) createTeam(tb testing.TB) *model.Team {
id := model.NewId()
team := &model.Team{
DisplayName: "dn_" + id,
Name: "name" + id,
Email: "success+" + id + "@simulator.amazonses.com",
Type: model.TeamOpen,
}
team, appErr := th.App.CreateTeam(th.Context, team)
require.Nil(tb, appErr)
return team
}
func (th *TestHelper) createUser(tb testing.TB) *model.User {
return th.createUserOrGuest(tb, false)
}
func (th *TestHelper) createGuest(tb testing.TB) *model.User {
return th.createUserOrGuest(tb, true)
}
func (th *TestHelper) createUserOrGuest(tb testing.TB, guest bool) *model.User {
id := model.NewId()
user := &model.User{
Email: "success+" + id + "@simulator.amazonses.com",
Username: "un_" + id,
Nickname: "nn_" + id,
Password: model.NewTestPassword(),
EmailVerified: true,
}
var appErr *model.AppError
if guest {
user, appErr = th.App.CreateGuest(th.Context, user)
require.Nil(tb, appErr)
} else {
user, appErr = th.App.CreateUser(th.Context, user)
require.Nil(tb, appErr)
}
return user
}
type ChannelOption func(*model.Channel)
func WithShared(v bool) ChannelOption {
return func(channel *model.Channel) {
channel.Shared = new(v)
}
}
func (th *TestHelper) CreateChannel(tb testing.TB, team *model.Team, options ...ChannelOption) *model.Channel {
return th.createChannel(tb, team, model.ChannelTypeOpen, options...)
}
func (th *TestHelper) createPrivateChannel(tb testing.TB, team *model.Team) *model.Channel {
return th.createChannel(tb, team, model.ChannelTypePrivate)
}
func (th *TestHelper) createChannel(tb testing.TB, team *model.Team, channelType model.ChannelType, options ...ChannelOption) *model.Channel {
id := model.NewId()
channel := &model.Channel{
DisplayName: "dn_" + id,
Name: "name_" + id,
Type: channelType,
TeamId: team.Id,
CreatorId: th.BasicUser.Id,
}
for _, option := range options {
option(channel)
}
channel, appErr := th.App.CreateChannel(th.Context, channel, true)
require.Nil(tb, appErr)
if channel.IsShared() {
id := model.NewId()
_, err := th.App.ShareChannel(th.Context, &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(),
})
require.NoError(tb, err)
}
return channel
}
func (th *TestHelper) createChannelWithAnotherUser(tb testing.TB, team *model.Team, channelType model.ChannelType, userID string) *model.Channel {
id := model.NewId()
channel := &model.Channel{
DisplayName: "dn_" + id,
Name: "name_" + id,
Type: channelType,
TeamId: team.Id,
CreatorId: userID,
}
channel, appErr := th.App.CreateChannel(th.Context, channel, true)
require.Nil(tb, appErr)
return channel
}
func (th *TestHelper) createDmChannel(tb testing.TB, user *model.User) *model.Channel {
channel, appErr := th.App.GetOrCreateDirectChannel(th.Context, th.BasicUser.Id, user.Id)
require.Nil(tb, appErr)
return channel
}
func (th *TestHelper) createGroupChannel(tb testing.TB, user1 *model.User, user2 *model.User) *model.Channel {
channel, appErr := th.App.CreateGroupChannel(th.Context, []string{th.BasicUser.Id, user1.Id, user2.Id}, th.BasicUser.Id)
require.Nil(tb, appErr)
return channel
}
func (th *TestHelper) createPost(tb testing.TB, channel *model.Channel) *model.Post {
id := model.NewId()
post := &model.Post{
UserId: th.BasicUser.Id,
ChannelId: channel.Id,
Message: "message_" + id,
CreateAt: model.GetMillis() - 10000,
}
post, _, appErr := th.App.CreatePost(th.Context, post, channel, model.CreatePostFlags{SetOnline: true})
require.Nil(tb, appErr)
return post
}
func (th *TestHelper) linkUserToTeam(tb testing.TB, user *model.User, team *model.Team) {
_, appErr := th.App.JoinUserToTeam(th.Context, team, user, "")
require.Nil(tb, appErr)
}
func (th *TestHelper) addUserToChannel(tb testing.TB, user *model.User, channel *model.Channel) *model.ChannelMember {
member, appErr := th.App.AddUserToChannel(th.Context, user, channel, false)
require.Nil(tb, appErr)
return member
}
func (th *TestHelper) removePermissionFromRole(tb testing.TB, permission string, roleName string) {
role, appErr := th.App.GetRoleByName(th.Context, roleName)
require.Nil(tb, appErr)
var newPermissions []string
for _, p := range role.Permissions {
if p != permission {
newPermissions = append(newPermissions, p)
}
}
if strings.Join(role.Permissions, " ") == strings.Join(newPermissions, " ") {
return
}
role.Permissions = newPermissions
_, appErr = th.App.UpdateRole(role)
require.Nil(tb, appErr)
}
func (th *TestHelper) addPermissionToRole(tb testing.TB, permission string, roleName string) {
role, appErr := th.App.GetRoleByName(th.Context, roleName)
require.Nil(tb, appErr)
if slices.Contains(role.Permissions, permission) {
return
}
role.Permissions = append(role.Permissions, permission)
_, appErr = th.App.UpdateRole(role)
require.Nil(tb, appErr)
}

View File

@@ -0,0 +1,24 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package slashcommands
import (
"testing"
"github.com/mattermost/mattermost/server/v8/channels/testlib"
)
var mainHelper *testlib.MainHelper
func TestMain(m *testing.M) {
var options = testlib.HelperOptions{
EnableStore: true,
EnableResources: true,
}
mainHelper = testlib.NewMainHelperWithOptions(&options)
defer mainHelper.Close()
mainHelper.Main(m)
}

View File

@@ -0,0 +1,102 @@
// 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/public/model"
"github.com/mattermost/mattermost/server/public/shared/i18n"
)
const (
ActionKey = "-action"
)
// response creates an ephemeral command response with the given message.
func response(message string) *model.CommandResponse {
return &model.CommandResponse{
ResponseType: model.CommandResponseTypeEphemeral,
Text: message,
Type: model.PostTypeDefault,
}
}
// 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 := range split {
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 formatBool(fn i18n.TranslateFunc, b bool) string {
if b {
return fn("True")
}
return fn("False")
}
func formatTimestamp(timestamp int64) string {
if timestamp == 0 {
return "--"
}
ts := model.GetTimeForMillis(timestamp)
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
}

View File

@@ -0,0 +1,63 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package slashcommands
import (
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/mattermost/mattermost/server/public/model"
)
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)
}
}
func TestFormatTimestamp(t *testing.T) {
t.Run("zero renders as placeholder", func(t *testing.T) {
assert.Equal(t, "--", formatTimestamp(0))
})
t.Run("today renders with a Today prefix", func(t *testing.T) {
got := formatTimestamp(model.GetMillis())
assert.True(t, strings.HasPrefix(got, "Today "), "expected a Today prefix, got %q", got)
})
t.Run("an earlier day renders the full date without the placeholder", func(t *testing.T) {
twoDaysAgo := model.GetMillis() - int64(48*time.Hour/time.Millisecond)
got := formatTimestamp(twoDaysAgo)
assert.NotEqual(t, "--", got)
assert.False(t, strings.HasPrefix(got, "Today "), "expected a full date, got %q", got)
assert.Equal(t, model.GetTimeForMillis(twoDaysAgo).Format("Jan 2 15:04:05 MST 2006"), got)
})
}