MM-12393 Server side of bot accounts. (#10378)
* bots model, store and api (#9903)
* bots model, store and api
Fixes: MM-13100, MM-13101, MM-13103, MM-13105, MMM-13119
* uncomment tests incorrectly commented, and fix merge issues
* add etags support
* add missing licenses
* remove unused sqlbuilder.go (for now...)
* rejig permissions
* split out READ_BOTS into READ_BOTS and READ_OTHERS_BOTS, the latter
implicitly allowing the former
* make MANAGE_OTHERS_BOTS imply MANAGE_BOTS
* conform to general rest api pattern
* eliminate redundant http.StatusOK
* Update api4/bot.go
Co-Authored-By: lieut-data <jesse.hallam@gmail.com>
* s/model.UserFromBotModel/model.UserFromBot/g
* Update model/bot.go
Co-Authored-By: lieut-data <jesse.hallam@gmail.com>
* Update model/client4.go
Co-Authored-By: lieut-data <jesse.hallam@gmail.com>
* move sessionHasPermissionToManageBot to app/authorization.go
* use api.ApiSessionRequired for createBot
* introduce BOT_DESCRIPTION_MAX_RUNES constant
* MM-13512 Prevent getting a user by email based on privacy settings (#10021)
* MM-13512 Prevent getting a user by email based on privacy settings
* Add additional config settings to tests
* upgrade db to 5.7 (#10019)
* MM-13526 Add validation when setting a user's Locale field (#10022)
* Fix typos (#10024)
* Fixing first user being created with system admin privilages without being explicity specified. (#10014)
* Revert "Support for Embeded chat (#9129)" (#10017)
This reverts commit 3fcecd521a.
* s/DisableBot/UpdateBotActive
* add permissions on upgrade
* Update NOTICE.txt (#10054)
- add new dependency (text)
- handle switch to forked dependency (go-gomail -> go-mail)
- misc copyright owner updates
* avoid leaking bot knowledge without permission
* [GH-6798] added a new api endpoint to get the bulk reactions for posts (#10049)
* 6798 added a new api to get the bulk reactions for posts
* 6798 added the permsission check before getting the reactions
* GH-6798 added a new app function for the new endpoint
* 6798 added a store method to get reactions for multiple posts
* 6798 connected the app function with the new store function
* 6798 fixed the review comments
* MM-13559 Update model.post.is_valid.file_ids.app_error text per report (#10055)
Ticket: https://mattermost.atlassian.net/browse/MM-13559
Report: https://github.com/mattermost/mattermost-server/issues/10023
* Trigger Login Hooks with OAuth (#10061)
* make BotStore.GetAll deterministic even on duplicate CreateAt
* fix spurious TestMuteCommandSpecificChannel test failure
See
https://community-daily.mattermost.com/core/pl/px9p8s3dzbg1pf3ddrm5cr36uw
* fix race in TestExportUserChannels
* TestExportUserChannels: remove SaveMember call, as it is redundant and used to be silently failing anyway
* MM-13117: bot tokens (#10111)
* eliminate redundant Client/AdminClient declarations
* harden TestUpdateChannelScheme to API failures
* eliminate unnecessary config restoration
* minor cleanup
* make TestGenerateMfaSecret config dependency explicit
* TestCreateUserAccessToken for bots
* TestGetUserAccessToken* for bots
* leverage SessionHasPermissionToUserOrBot for user token APIs
* Test(Revoke|Disable|Enable)UserAccessToken
* make EnableUserAccessTokens explicit, so as to not rely on local config.json
* uncomment TestResetPassword, but still skip
* mark assert(Invalid)Token as helper
* fix whitespace issues
* fix mangled comments
* MM-13116: bot plugin api (#10113)
* MM-13117: expose bot API to plugins
This also changes the `CreatorId` column definition to allow for plugin
ids, as the default unless the plugin overrides is to use the plugin id
here. This branch hasn't hit master yet, so no migration needed.
* gofmt issues
* expunge use of BotList in plugin/client API
* introduce model.BotGetOptions
* use botUserId term for clarity
* MM-13129 Adding functionality to deal with orphaned bots (#10238)
* Add way to list orphaned bots.
* Add /assign route to modify ownership of bot accounts.
* Apply suggestions from code review
Co-Authored-By: crspeller <crspeller@gmail.com>
* MM-13120: add IsBot field to returned user objects (#10103)
* MM-13104: forbid bot login (#10251)
* MM-13104: disallow bot login
* fix shadowing
* MM-13136 Disable user bots when user is disabled. (#10293)
* Disable user bots when user is disabled.
* Grammer.
Co-Authored-By: crspeller <crspeller@gmail.com>
* Fixing bot branch for test changes.
* Don't use external dependancies in bot plugin tests.
* Rename bot CreatorId to OwnerId
* Adding ability to re-enable bots
* Fixing IsBot to not attempt to be saved to DB.
* Adding diagnostics and licencing counting for bot accounts.
* Modifying gorp to allow reading of '-' fields.
* Removing unnessisary nil values from UserCountOptions.
* Changing comment to GoDoc format
* Improving user count SQL
* Some improvments from feedback.
* Omit empty on User.IsBot
Этот коммит содержится в:
коммит произвёл
GitHub
родитель
80e0d01fe5
Коммит
06b579d18a
209
model/bot.go
Обычный файл
209
model/bot.go
Обычный файл
@@ -0,0 +1,209 @@
|
||||
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
|
||||
// See License.txt for license information.
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const (
|
||||
BOT_DISPLAY_NAME_MAX_RUNES = USER_FIRST_NAME_MAX_RUNES
|
||||
BOT_DESCRIPTION_MAX_RUNES = 1024
|
||||
BOT_CREATOR_ID_MAX_RUNES = KEY_VALUE_PLUGIN_ID_MAX_RUNES // UserId or PluginId
|
||||
)
|
||||
|
||||
// Bot is a special type of User meant for programmatic interactions.
|
||||
// Note that the primary key of a bot is the UserId, and matches the primary key of the
|
||||
// corresponding user.
|
||||
type Bot struct {
|
||||
UserId string `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
OwnerId string `json:"creator_id"`
|
||||
CreateAt int64 `json:"create_at"`
|
||||
UpdateAt int64 `json:"update_at"`
|
||||
DeleteAt int64 `json:"delete_at"`
|
||||
}
|
||||
|
||||
// BotPatch is a description of what fields to update on an existing bot.
|
||||
type BotPatch struct {
|
||||
Username *string `json:"username"`
|
||||
DisplayName *string `json:"display_name"`
|
||||
Description *string `json:"description"`
|
||||
}
|
||||
|
||||
// BotGetOptions acts as a filter on bulk bot fetching queries.
|
||||
type BotGetOptions struct {
|
||||
OwnerId string
|
||||
IncludeDeleted bool
|
||||
OnlyOrphaned bool
|
||||
Page int
|
||||
PerPage int
|
||||
}
|
||||
|
||||
// BotList is a list of bots.
|
||||
type BotList []*Bot
|
||||
|
||||
// Trace describes the minimum information required to identify a bot for the purpose of logging.
|
||||
func (b *Bot) Trace() map[string]interface{} {
|
||||
return map[string]interface{}{"user_id": b.UserId}
|
||||
}
|
||||
|
||||
// Clone returns a shallow copy of the bot.
|
||||
func (b *Bot) Clone() *Bot {
|
||||
copy := *b
|
||||
return ©
|
||||
}
|
||||
|
||||
// IsValid validates the bot and returns an error if it isn't configured correctly.
|
||||
func (b *Bot) IsValid() *AppError {
|
||||
if !IsValidId(b.UserId) {
|
||||
return NewAppError("Bot.IsValid", "model.bot.is_valid.user_id.app_error", b.Trace(), "", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
if !IsValidUsername(b.Username) {
|
||||
return NewAppError("Bot.IsValid", "model.bot.is_valid.username.app_error", b.Trace(), "", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
if utf8.RuneCountInString(b.DisplayName) > BOT_DISPLAY_NAME_MAX_RUNES {
|
||||
return NewAppError("Bot.IsValid", "model.bot.is_valid.user_id.app_error", b.Trace(), "", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
if utf8.RuneCountInString(b.Description) > BOT_DESCRIPTION_MAX_RUNES {
|
||||
return NewAppError("Bot.IsValid", "model.bot.is_valid.description.app_error", b.Trace(), "", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
if len(b.OwnerId) == 0 || utf8.RuneCountInString(b.OwnerId) > BOT_CREATOR_ID_MAX_RUNES {
|
||||
return NewAppError("Bot.IsValid", "model.bot.is_valid.creator_id.app_error", b.Trace(), "", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
if b.CreateAt == 0 {
|
||||
return NewAppError("Bot.IsValid", "model.bot.is_valid.create_at.app_error", b.Trace(), "", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
if b.UpdateAt == 0 {
|
||||
return NewAppError("Bot.IsValid", "model.bot.is_valid.update_at.app_error", b.Trace(), "", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// PreSave should be run before saving a new bot to the database.
|
||||
func (b *Bot) PreSave() {
|
||||
b.CreateAt = GetMillis()
|
||||
b.UpdateAt = b.CreateAt
|
||||
b.DeleteAt = 0
|
||||
}
|
||||
|
||||
// PreUpdate should be run before saving an updated bot to the database.
|
||||
func (b *Bot) PreUpdate() {
|
||||
b.UpdateAt = GetMillis()
|
||||
}
|
||||
|
||||
// Etag generates an etag for caching.
|
||||
func (b *Bot) Etag() string {
|
||||
return Etag(b.UserId, b.UpdateAt)
|
||||
}
|
||||
|
||||
// ToJson serializes the bot to json.
|
||||
func (b *Bot) ToJson() []byte {
|
||||
data, _ := json.Marshal(b)
|
||||
return data
|
||||
}
|
||||
|
||||
// BotFromJson deserializes a bot from json.
|
||||
func BotFromJson(data io.Reader) *Bot {
|
||||
var bot *Bot
|
||||
json.NewDecoder(data).Decode(&bot)
|
||||
return bot
|
||||
}
|
||||
|
||||
// Patch modifies an existing bot with optional fields from the given patch.
|
||||
func (b *Bot) Patch(patch *BotPatch) {
|
||||
if patch.Username != nil {
|
||||
b.Username = *patch.Username
|
||||
}
|
||||
|
||||
if patch.DisplayName != nil {
|
||||
b.DisplayName = *patch.DisplayName
|
||||
}
|
||||
|
||||
if patch.Description != nil {
|
||||
b.Description = *patch.Description
|
||||
}
|
||||
}
|
||||
|
||||
// ToJson serializes the bot patch to json.
|
||||
func (b *BotPatch) ToJson() []byte {
|
||||
data, err := json.Marshal(b)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
// BotPatchFromJson deserializes a bot patch from json.
|
||||
func BotPatchFromJson(data io.Reader) *BotPatch {
|
||||
decoder := json.NewDecoder(data)
|
||||
var botPatch BotPatch
|
||||
err := decoder.Decode(&botPatch)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &botPatch
|
||||
}
|
||||
|
||||
// UserFromBot returns a user model describing the bot fields stored in the User store.
|
||||
func UserFromBot(b *Bot) *User {
|
||||
return &User{
|
||||
Id: b.UserId,
|
||||
Username: b.Username,
|
||||
Email: fmt.Sprintf("%s@localhost", strings.ToLower(b.Username)),
|
||||
FirstName: b.DisplayName,
|
||||
}
|
||||
}
|
||||
|
||||
// BotListFromJson deserializes a list of bots from json.
|
||||
func BotListFromJson(data io.Reader) BotList {
|
||||
var bots BotList
|
||||
json.NewDecoder(data).Decode(&bots)
|
||||
return bots
|
||||
}
|
||||
|
||||
// ToJson serializes a list of bots to json.
|
||||
func (l *BotList) ToJson() []byte {
|
||||
b, _ := json.Marshal(l)
|
||||
return b
|
||||
}
|
||||
|
||||
// Etag computes the etag for a list of bots.
|
||||
func (l *BotList) Etag() string {
|
||||
id := "0"
|
||||
var t int64 = 0
|
||||
var delta int64 = 0
|
||||
|
||||
for _, v := range *l {
|
||||
if v.UpdateAt > t {
|
||||
t = v.UpdateAt
|
||||
id = v.UserId
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return Etag(id, t, delta, len(*l))
|
||||
}
|
||||
|
||||
// MakeBotNotFoundError creates the error returned when a bot does not exist, or when the user isn't allowed to query the bot.
|
||||
// The errors must the same in both cases to avoid leaking that a user is a bot.
|
||||
func MakeBotNotFoundError(userId string) *AppError {
|
||||
return NewAppError("SqlBotStore.Get", "store.sql_bot.get.missing.app_error", map[string]interface{}{"user_id": userId}, "", http.StatusNotFound)
|
||||
}
|
||||
666
model/bot_test.go
Обычный файл
666
model/bot_test.go
Обычный файл
@@ -0,0 +1,666 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See License.txt for license information.
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestBotTrace(t *testing.T) {
|
||||
bot := &Bot{
|
||||
UserId: NewId(),
|
||||
Username: "username",
|
||||
DisplayName: "display name",
|
||||
Description: "description",
|
||||
OwnerId: NewId(),
|
||||
CreateAt: 1,
|
||||
UpdateAt: 2,
|
||||
DeleteAt: 3,
|
||||
}
|
||||
|
||||
require.Equal(t, map[string]interface{}{"user_id": bot.UserId}, bot.Trace())
|
||||
}
|
||||
|
||||
func TestBotClone(t *testing.T) {
|
||||
bot := &Bot{
|
||||
UserId: NewId(),
|
||||
Username: "username",
|
||||
DisplayName: "display name",
|
||||
Description: "description",
|
||||
OwnerId: NewId(),
|
||||
CreateAt: 1,
|
||||
UpdateAt: 2,
|
||||
DeleteAt: 3,
|
||||
}
|
||||
|
||||
clone := bot.Clone()
|
||||
|
||||
require.Equal(t, bot, bot.Clone())
|
||||
require.False(t, bot == clone)
|
||||
}
|
||||
|
||||
func TestBotIsValid(t *testing.T) {
|
||||
testCases := []struct {
|
||||
Description string
|
||||
Bot *Bot
|
||||
ExpectedIsValid bool
|
||||
}{
|
||||
{
|
||||
"nil bot",
|
||||
&Bot{},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"bot with missing user id",
|
||||
&Bot{
|
||||
UserId: "",
|
||||
Username: "username",
|
||||
DisplayName: "display name",
|
||||
Description: "description",
|
||||
OwnerId: NewId(),
|
||||
CreateAt: 1,
|
||||
UpdateAt: 2,
|
||||
DeleteAt: 3,
|
||||
},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"bot with invalid user id",
|
||||
&Bot{
|
||||
UserId: "invalid",
|
||||
Username: "username",
|
||||
DisplayName: "display name",
|
||||
Description: "description",
|
||||
OwnerId: NewId(),
|
||||
CreateAt: 1,
|
||||
UpdateAt: 2,
|
||||
DeleteAt: 3,
|
||||
},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"bot with missing username",
|
||||
&Bot{
|
||||
UserId: NewId(),
|
||||
Username: "",
|
||||
DisplayName: "display name",
|
||||
Description: "description",
|
||||
OwnerId: NewId(),
|
||||
CreateAt: 1,
|
||||
UpdateAt: 2,
|
||||
DeleteAt: 3,
|
||||
},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"bot with invalid username",
|
||||
&Bot{
|
||||
UserId: NewId(),
|
||||
Username: "a@",
|
||||
DisplayName: "display name",
|
||||
Description: "description",
|
||||
OwnerId: NewId(),
|
||||
CreateAt: 1,
|
||||
UpdateAt: 2,
|
||||
DeleteAt: 3,
|
||||
},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"bot with long description",
|
||||
&Bot{
|
||||
UserId: "",
|
||||
Username: "username",
|
||||
DisplayName: "display name",
|
||||
Description: strings.Repeat("x", 1025),
|
||||
OwnerId: NewId(),
|
||||
CreateAt: 1,
|
||||
UpdateAt: 2,
|
||||
DeleteAt: 3,
|
||||
},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"bot with missing creator id",
|
||||
&Bot{
|
||||
UserId: NewId(),
|
||||
Username: "username",
|
||||
DisplayName: "display name",
|
||||
Description: "description",
|
||||
OwnerId: "",
|
||||
CreateAt: 1,
|
||||
UpdateAt: 2,
|
||||
DeleteAt: 3,
|
||||
},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"bot without create at timestamp",
|
||||
&Bot{
|
||||
UserId: NewId(),
|
||||
Username: "username",
|
||||
DisplayName: "display name",
|
||||
Description: "description",
|
||||
OwnerId: NewId(),
|
||||
CreateAt: 0,
|
||||
UpdateAt: 2,
|
||||
DeleteAt: 3,
|
||||
},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"bot without update at timestamp",
|
||||
&Bot{
|
||||
UserId: NewId(),
|
||||
Username: "username",
|
||||
DisplayName: "display name",
|
||||
Description: "description",
|
||||
OwnerId: NewId(),
|
||||
CreateAt: 1,
|
||||
UpdateAt: 0,
|
||||
DeleteAt: 3,
|
||||
},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"bot",
|
||||
&Bot{
|
||||
UserId: NewId(),
|
||||
Username: "username",
|
||||
DisplayName: "display name",
|
||||
Description: "description",
|
||||
OwnerId: NewId(),
|
||||
CreateAt: 1,
|
||||
UpdateAt: 2,
|
||||
DeleteAt: 0,
|
||||
},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"bot without description",
|
||||
&Bot{
|
||||
UserId: NewId(),
|
||||
Username: "username",
|
||||
DisplayName: "display name",
|
||||
Description: "",
|
||||
OwnerId: NewId(),
|
||||
CreateAt: 1,
|
||||
UpdateAt: 2,
|
||||
DeleteAt: 0,
|
||||
},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"deleted bot",
|
||||
&Bot{
|
||||
UserId: NewId(),
|
||||
Username: "username",
|
||||
DisplayName: "display name",
|
||||
Description: "a description",
|
||||
OwnerId: NewId(),
|
||||
CreateAt: 1,
|
||||
UpdateAt: 2,
|
||||
DeleteAt: 3,
|
||||
},
|
||||
true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.Description, func(t *testing.T) {
|
||||
if testCase.ExpectedIsValid {
|
||||
require.Nil(t, testCase.Bot.IsValid())
|
||||
} else {
|
||||
require.NotNil(t, testCase.Bot.IsValid())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotPreSave(t *testing.T) {
|
||||
bot := &Bot{
|
||||
UserId: NewId(),
|
||||
Username: "username",
|
||||
DisplayName: "display name",
|
||||
Description: "description",
|
||||
OwnerId: NewId(),
|
||||
DeleteAt: 0,
|
||||
}
|
||||
|
||||
originalBot := &*bot
|
||||
|
||||
bot.PreSave()
|
||||
assert.NotEqual(t, 0, bot.CreateAt)
|
||||
assert.NotEqual(t, 0, bot.UpdateAt)
|
||||
|
||||
originalBot.CreateAt = bot.CreateAt
|
||||
originalBot.UpdateAt = bot.UpdateAt
|
||||
assert.Equal(t, originalBot, bot)
|
||||
}
|
||||
|
||||
func TestBotPreUpdate(t *testing.T) {
|
||||
bot := &Bot{
|
||||
UserId: NewId(),
|
||||
Username: "username",
|
||||
DisplayName: "display name",
|
||||
Description: "description",
|
||||
OwnerId: NewId(),
|
||||
CreateAt: 1,
|
||||
DeleteAt: 0,
|
||||
}
|
||||
|
||||
originalBot := &*bot
|
||||
|
||||
bot.PreSave()
|
||||
assert.NotEqual(t, 0, bot.UpdateAt)
|
||||
|
||||
originalBot.UpdateAt = bot.UpdateAt
|
||||
assert.Equal(t, originalBot, bot)
|
||||
}
|
||||
|
||||
func TestBotEtag(t *testing.T) {
|
||||
t.Run("same etags", func(t *testing.T) {
|
||||
bot1 := &Bot{
|
||||
UserId: NewId(),
|
||||
Username: "username",
|
||||
DisplayName: "display name",
|
||||
Description: "description",
|
||||
OwnerId: NewId(),
|
||||
CreateAt: 1,
|
||||
UpdateAt: 2,
|
||||
DeleteAt: 3,
|
||||
}
|
||||
bot2 := bot1
|
||||
|
||||
assert.Equal(t, bot1.Etag(), bot2.Etag())
|
||||
})
|
||||
t.Run("different etags", func(t *testing.T) {
|
||||
t.Run("different user id", func(t *testing.T) {
|
||||
bot1 := &Bot{
|
||||
UserId: NewId(),
|
||||
Username: "username",
|
||||
DisplayName: "display name",
|
||||
Description: "description",
|
||||
OwnerId: NewId(),
|
||||
CreateAt: 1,
|
||||
UpdateAt: 2,
|
||||
DeleteAt: 3,
|
||||
}
|
||||
bot2 := &Bot{
|
||||
UserId: NewId(),
|
||||
Username: "username",
|
||||
DisplayName: "display name",
|
||||
Description: "description",
|
||||
OwnerId: bot1.OwnerId,
|
||||
CreateAt: 1,
|
||||
UpdateAt: 2,
|
||||
DeleteAt: 3,
|
||||
}
|
||||
|
||||
assert.NotEqual(t, bot1.Etag(), bot2.Etag())
|
||||
})
|
||||
t.Run("different update at", func(t *testing.T) {
|
||||
bot1 := &Bot{
|
||||
UserId: NewId(),
|
||||
Username: "username",
|
||||
DisplayName: "display name",
|
||||
Description: "description",
|
||||
OwnerId: NewId(),
|
||||
CreateAt: 1,
|
||||
UpdateAt: 2,
|
||||
DeleteAt: 3,
|
||||
}
|
||||
bot2 := &Bot{
|
||||
UserId: bot1.UserId,
|
||||
Username: "username",
|
||||
DisplayName: "display name",
|
||||
Description: "description",
|
||||
OwnerId: bot1.OwnerId,
|
||||
CreateAt: 1,
|
||||
UpdateAt: 10,
|
||||
DeleteAt: 3,
|
||||
}
|
||||
|
||||
assert.NotEqual(t, bot1.Etag(), bot2.Etag())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestBotToAndFromJson(t *testing.T) {
|
||||
bot1 := &Bot{
|
||||
UserId: NewId(),
|
||||
Username: "username",
|
||||
DisplayName: "display name",
|
||||
Description: "description",
|
||||
OwnerId: NewId(),
|
||||
CreateAt: 1,
|
||||
UpdateAt: 2,
|
||||
DeleteAt: 3,
|
||||
}
|
||||
|
||||
bot2 := &Bot{
|
||||
UserId: NewId(),
|
||||
Username: "username",
|
||||
DisplayName: "display name",
|
||||
Description: "description 2",
|
||||
OwnerId: NewId(),
|
||||
CreateAt: 4,
|
||||
UpdateAt: 5,
|
||||
DeleteAt: 6,
|
||||
}
|
||||
|
||||
assert.Equal(t, bot1, BotFromJson(bytes.NewReader(bot1.ToJson())))
|
||||
assert.Equal(t, bot2, BotFromJson(bytes.NewReader(bot2.ToJson())))
|
||||
}
|
||||
|
||||
func sToP(s string) *string {
|
||||
return &s
|
||||
}
|
||||
|
||||
func TestBotPatch(t *testing.T) {
|
||||
userId1 := NewId()
|
||||
creatorId1 := NewId()
|
||||
|
||||
testCases := []struct {
|
||||
Description string
|
||||
Bot *Bot
|
||||
BotPatch *BotPatch
|
||||
ExpectedBot *Bot
|
||||
}{
|
||||
{
|
||||
"no update",
|
||||
&Bot{
|
||||
UserId: userId1,
|
||||
Username: "username",
|
||||
DisplayName: "display name",
|
||||
Description: "description",
|
||||
OwnerId: creatorId1,
|
||||
CreateAt: 1,
|
||||
UpdateAt: 2,
|
||||
DeleteAt: 3,
|
||||
},
|
||||
&BotPatch{},
|
||||
&Bot{
|
||||
UserId: userId1,
|
||||
Username: "username",
|
||||
DisplayName: "display name",
|
||||
Description: "description",
|
||||
OwnerId: creatorId1,
|
||||
CreateAt: 1,
|
||||
UpdateAt: 2,
|
||||
DeleteAt: 3,
|
||||
},
|
||||
},
|
||||
{
|
||||
"partial update",
|
||||
&Bot{
|
||||
UserId: userId1,
|
||||
Username: "username",
|
||||
DisplayName: "display name",
|
||||
Description: "description",
|
||||
OwnerId: creatorId1,
|
||||
CreateAt: 1,
|
||||
UpdateAt: 2,
|
||||
DeleteAt: 3,
|
||||
},
|
||||
&BotPatch{
|
||||
Username: sToP("new_username"),
|
||||
DisplayName: nil,
|
||||
Description: sToP("new description"),
|
||||
},
|
||||
&Bot{
|
||||
UserId: userId1,
|
||||
Username: "new_username",
|
||||
DisplayName: "display name",
|
||||
Description: "new description",
|
||||
OwnerId: creatorId1,
|
||||
CreateAt: 1,
|
||||
UpdateAt: 2,
|
||||
DeleteAt: 3,
|
||||
},
|
||||
},
|
||||
{
|
||||
"full update",
|
||||
&Bot{
|
||||
UserId: userId1,
|
||||
Username: "username",
|
||||
DisplayName: "display name",
|
||||
Description: "description",
|
||||
OwnerId: creatorId1,
|
||||
CreateAt: 1,
|
||||
UpdateAt: 2,
|
||||
DeleteAt: 3,
|
||||
},
|
||||
&BotPatch{
|
||||
Username: sToP("new_username"),
|
||||
DisplayName: sToP("new display name"),
|
||||
Description: sToP("new description"),
|
||||
},
|
||||
&Bot{
|
||||
UserId: userId1,
|
||||
Username: "new_username",
|
||||
DisplayName: "new display name",
|
||||
Description: "new description",
|
||||
OwnerId: creatorId1,
|
||||
CreateAt: 1,
|
||||
UpdateAt: 2,
|
||||
DeleteAt: 3,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.Description, func(t *testing.T) {
|
||||
testCase.Bot.Patch(testCase.BotPatch)
|
||||
assert.Equal(t, testCase.ExpectedBot, testCase.Bot)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotPatchToAndFromJson(t *testing.T) {
|
||||
botPatch1 := &BotPatch{
|
||||
Username: sToP("username"),
|
||||
DisplayName: sToP("display name"),
|
||||
Description: sToP("description"),
|
||||
}
|
||||
|
||||
botPatch2 := &BotPatch{
|
||||
Username: sToP("username"),
|
||||
DisplayName: sToP("display name"),
|
||||
Description: sToP("description 2"),
|
||||
}
|
||||
|
||||
assert.Equal(t, botPatch1, BotPatchFromJson(bytes.NewReader(botPatch1.ToJson())))
|
||||
assert.Equal(t, botPatch2, BotPatchFromJson(bytes.NewReader(botPatch2.ToJson())))
|
||||
}
|
||||
|
||||
func TestUserFromBot(t *testing.T) {
|
||||
bot1 := &Bot{
|
||||
UserId: NewId(),
|
||||
Username: "username",
|
||||
DisplayName: "display name",
|
||||
Description: "description",
|
||||
OwnerId: NewId(),
|
||||
CreateAt: 1,
|
||||
UpdateAt: 2,
|
||||
DeleteAt: 3,
|
||||
}
|
||||
|
||||
bot2 := &Bot{
|
||||
UserId: NewId(),
|
||||
Username: "username2",
|
||||
DisplayName: "display name 2",
|
||||
Description: "description 2",
|
||||
OwnerId: NewId(),
|
||||
CreateAt: 4,
|
||||
UpdateAt: 5,
|
||||
DeleteAt: 6,
|
||||
}
|
||||
|
||||
assert.Equal(t, &User{
|
||||
Id: bot1.UserId,
|
||||
Username: "username",
|
||||
Email: "username@localhost",
|
||||
FirstName: "display name",
|
||||
}, UserFromBot(bot1))
|
||||
assert.Equal(t, &User{
|
||||
Id: bot2.UserId,
|
||||
Username: "username2",
|
||||
Email: "username2@localhost",
|
||||
FirstName: "display name 2",
|
||||
}, UserFromBot(bot2))
|
||||
}
|
||||
|
||||
func TestBotListToAndFromJson(t *testing.T) {
|
||||
testCases := []struct {
|
||||
Description string
|
||||
BotList BotList
|
||||
}{
|
||||
{
|
||||
"empty list",
|
||||
BotList{},
|
||||
},
|
||||
{
|
||||
"single item",
|
||||
BotList{
|
||||
&Bot{
|
||||
UserId: NewId(),
|
||||
Username: "username",
|
||||
DisplayName: "display name",
|
||||
Description: "description",
|
||||
OwnerId: NewId(),
|
||||
CreateAt: 1,
|
||||
UpdateAt: 2,
|
||||
DeleteAt: 3,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"multiple items",
|
||||
BotList{
|
||||
&Bot{
|
||||
UserId: NewId(),
|
||||
Username: "username",
|
||||
DisplayName: "display name",
|
||||
Description: "description",
|
||||
OwnerId: NewId(),
|
||||
CreateAt: 1,
|
||||
UpdateAt: 2,
|
||||
DeleteAt: 3,
|
||||
},
|
||||
|
||||
&Bot{
|
||||
UserId: NewId(),
|
||||
Username: "username",
|
||||
DisplayName: "display name",
|
||||
Description: "description 2",
|
||||
OwnerId: NewId(),
|
||||
CreateAt: 4,
|
||||
UpdateAt: 5,
|
||||
DeleteAt: 6,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.Description, func(t *testing.T) {
|
||||
assert.Equal(t, testCase.BotList, BotListFromJson(bytes.NewReader(testCase.BotList.ToJson())))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotListEtag(t *testing.T) {
|
||||
bot1 := &Bot{
|
||||
UserId: NewId(),
|
||||
Username: "username",
|
||||
DisplayName: "display name",
|
||||
Description: "description",
|
||||
OwnerId: NewId(),
|
||||
CreateAt: 1,
|
||||
UpdateAt: 2,
|
||||
DeleteAt: 3,
|
||||
}
|
||||
|
||||
bot1Updated := &Bot{
|
||||
UserId: NewId(),
|
||||
Username: "username",
|
||||
DisplayName: "display name",
|
||||
Description: "description",
|
||||
OwnerId: NewId(),
|
||||
CreateAt: 1,
|
||||
UpdateAt: 10,
|
||||
DeleteAt: 3,
|
||||
}
|
||||
|
||||
bot2 := &Bot{
|
||||
UserId: NewId(),
|
||||
Username: "username",
|
||||
DisplayName: "display name",
|
||||
Description: "description",
|
||||
OwnerId: NewId(),
|
||||
CreateAt: 4,
|
||||
UpdateAt: 5,
|
||||
DeleteAt: 6,
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
Description string
|
||||
BotListA BotList
|
||||
BotListB BotList
|
||||
ExpectedEqual bool
|
||||
}{
|
||||
{
|
||||
"empty lists",
|
||||
BotList{},
|
||||
BotList{},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"single item, same list",
|
||||
BotList{bot1},
|
||||
BotList{bot1},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"single item, different update at",
|
||||
BotList{bot1},
|
||||
BotList{bot1Updated},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"single item vs. multiple items",
|
||||
BotList{bot1},
|
||||
BotList{bot1, bot2},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"multiple items, different update at",
|
||||
BotList{bot1, bot2},
|
||||
BotList{bot1Updated, bot2},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"multiple items, same list",
|
||||
BotList{bot1, bot2},
|
||||
BotList{bot1, bot2},
|
||||
true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.Description, func(t *testing.T) {
|
||||
if testCase.ExpectedEqual {
|
||||
assert.Equal(t, testCase.BotListA.Etag(), testCase.BotListB.Etag())
|
||||
} else {
|
||||
assert.NotEqual(t, testCase.BotListA.Etag(), testCase.BotListB.Etag())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
117
model/client4.go
117
model/client4.go
@@ -150,6 +150,14 @@ func (c *Client4) GetUserByEmailRoute(email string) string {
|
||||
return fmt.Sprintf(c.GetUsersRoute()+"/email/%v", email)
|
||||
}
|
||||
|
||||
func (c *Client4) GetBotsRoute() string {
|
||||
return fmt.Sprintf("/bots")
|
||||
}
|
||||
|
||||
func (c *Client4) GetBotRoute(botUserId string) string {
|
||||
return fmt.Sprintf("%s/%s", c.GetBotsRoute(), botUserId)
|
||||
}
|
||||
|
||||
func (c *Client4) GetTeamsRoute() string {
|
||||
return fmt.Sprintf("/teams")
|
||||
}
|
||||
@@ -442,6 +450,10 @@ func (c *Client4) DoApiPut(url string, data string) (*http.Response, *AppError)
|
||||
return c.DoApiRequest(http.MethodPut, c.ApiUrl+url, data, "")
|
||||
}
|
||||
|
||||
func (c *Client4) doApiPutBytes(url string, data []byte) (*http.Response, *AppError) {
|
||||
return c.doApiRequestBytes(http.MethodPut, c.ApiUrl+url, data, "")
|
||||
}
|
||||
|
||||
func (c *Client4) DoApiDelete(url string) (*http.Response, *AppError) {
|
||||
return c.DoApiRequest(http.MethodDelete, c.ApiUrl+url, "", "")
|
||||
}
|
||||
@@ -1335,6 +1347,111 @@ func (c *Client4) EnableUserAccessToken(tokenId string) (bool, *Response) {
|
||||
return CheckStatusOK(r), BuildResponse(r)
|
||||
}
|
||||
|
||||
// Bots section
|
||||
|
||||
// CreateBot creates a bot in the system based on the provided bot struct.
|
||||
func (c *Client4) CreateBot(bot *Bot) (*Bot, *Response) {
|
||||
r, err := c.doApiPostBytes(c.GetBotsRoute(), bot.ToJson())
|
||||
if err != nil {
|
||||
return nil, BuildErrorResponse(r, err)
|
||||
}
|
||||
defer closeBody(r)
|
||||
return BotFromJson(r.Body), BuildResponse(r)
|
||||
}
|
||||
|
||||
// PatchBot partially updates a bot. Any missing fields are not updated.
|
||||
func (c *Client4) PatchBot(userId string, patch *BotPatch) (*Bot, *Response) {
|
||||
r, err := c.doApiPutBytes(c.GetBotRoute(userId), patch.ToJson())
|
||||
if err != nil {
|
||||
return nil, BuildErrorResponse(r, err)
|
||||
}
|
||||
defer closeBody(r)
|
||||
return BotFromJson(r.Body), BuildResponse(r)
|
||||
}
|
||||
|
||||
// GetBot fetches the given, undeleted bot.
|
||||
func (c *Client4) GetBot(userId string, etag string) (*Bot, *Response) {
|
||||
r, err := c.DoApiGet(c.GetBotRoute(userId), etag)
|
||||
if err != nil {
|
||||
return nil, BuildErrorResponse(r, err)
|
||||
}
|
||||
defer closeBody(r)
|
||||
return BotFromJson(r.Body), BuildResponse(r)
|
||||
}
|
||||
|
||||
// GetBot fetches the given bot, even if it is deleted.
|
||||
func (c *Client4) GetBotIncludeDeleted(userId string, etag string) (*Bot, *Response) {
|
||||
r, err := c.DoApiGet(c.GetBotRoute(userId)+"?include_deleted=true", etag)
|
||||
if err != nil {
|
||||
return nil, BuildErrorResponse(r, err)
|
||||
}
|
||||
defer closeBody(r)
|
||||
return BotFromJson(r.Body), BuildResponse(r)
|
||||
}
|
||||
|
||||
// GetBots fetches the given page of bots, excluding deleted.
|
||||
func (c *Client4) GetBots(page, perPage int, etag string) ([]*Bot, *Response) {
|
||||
query := fmt.Sprintf("?page=%v&per_page=%v", page, perPage)
|
||||
r, err := c.DoApiGet(c.GetBotsRoute()+query, etag)
|
||||
if err != nil {
|
||||
return nil, BuildErrorResponse(r, err)
|
||||
}
|
||||
defer closeBody(r)
|
||||
return BotListFromJson(r.Body), BuildResponse(r)
|
||||
}
|
||||
|
||||
// GetBotsIncludeDeleted fetches the given page of bots, including deleted.
|
||||
func (c *Client4) GetBotsIncludeDeleted(page, perPage int, etag string) ([]*Bot, *Response) {
|
||||
query := fmt.Sprintf("?page=%v&per_page=%v&include_deleted=true", page, perPage)
|
||||
r, err := c.DoApiGet(c.GetBotsRoute()+query, etag)
|
||||
if err != nil {
|
||||
return nil, BuildErrorResponse(r, err)
|
||||
}
|
||||
defer closeBody(r)
|
||||
return BotListFromJson(r.Body), BuildResponse(r)
|
||||
}
|
||||
|
||||
// GetBotsOrphaned fetches the given page of bots, only including orphanded bots.
|
||||
func (c *Client4) GetBotsOrphaned(page, perPage int, etag string) ([]*Bot, *Response) {
|
||||
query := fmt.Sprintf("?page=%v&per_page=%v&only_orphaned=true", page, perPage)
|
||||
r, err := c.DoApiGet(c.GetBotsRoute()+query, etag)
|
||||
if err != nil {
|
||||
return nil, BuildErrorResponse(r, err)
|
||||
}
|
||||
defer closeBody(r)
|
||||
return BotListFromJson(r.Body), BuildResponse(r)
|
||||
}
|
||||
|
||||
// DisableBot disables the given bot in the system.
|
||||
func (c *Client4) DisableBot(botUserId string) (*Bot, *Response) {
|
||||
r, err := c.doApiPostBytes(c.GetBotRoute(botUserId)+"/disable", nil)
|
||||
if err != nil {
|
||||
return nil, BuildErrorResponse(r, err)
|
||||
}
|
||||
defer closeBody(r)
|
||||
return BotFromJson(r.Body), BuildResponse(r)
|
||||
}
|
||||
|
||||
// EnableBot disables the given bot in the system.
|
||||
func (c *Client4) EnableBot(botUserId string) (*Bot, *Response) {
|
||||
r, err := c.doApiPostBytes(c.GetBotRoute(botUserId)+"/enable", nil)
|
||||
if err != nil {
|
||||
return nil, BuildErrorResponse(r, err)
|
||||
}
|
||||
defer closeBody(r)
|
||||
return BotFromJson(r.Body), BuildResponse(r)
|
||||
}
|
||||
|
||||
// AssignBot assigns the given bot to the given user
|
||||
func (c *Client4) AssignBot(botUserId, newOwnerId string) (*Bot, *Response) {
|
||||
r, err := c.doApiPostBytes(c.GetBotRoute(botUserId)+"/assign/"+newOwnerId, nil)
|
||||
if err != nil {
|
||||
return nil, BuildErrorResponse(r, err)
|
||||
}
|
||||
defer closeBody(r)
|
||||
return BotFromJson(r.Body), BuildResponse(r)
|
||||
}
|
||||
|
||||
// Team Section
|
||||
|
||||
// CreateTeam creates a team in the system based on the provided team struct.
|
||||
|
||||
@@ -287,6 +287,7 @@ type ServiceSettings struct {
|
||||
ExperimentalStrictCSRFEnforcement *bool
|
||||
EnableEmailInvitations *bool
|
||||
ExperimentalLdapGroupSync *bool
|
||||
DisableBotsWhenOwnerIsDeactivated *bool
|
||||
}
|
||||
|
||||
func (s *ServiceSettings) SetDefaults() {
|
||||
@@ -621,6 +622,10 @@ func (s *ServiceSettings) SetDefaults() {
|
||||
if s.ExperimentalStrictCSRFEnforcement == nil {
|
||||
s.ExperimentalStrictCSRFEnforcement = NewBool(false)
|
||||
}
|
||||
|
||||
if s.DisableBotsWhenOwnerIsDeactivated == nil {
|
||||
s.DisableBotsWhenOwnerIsDeactivated = NewBool(true)
|
||||
}
|
||||
}
|
||||
|
||||
type ClusterSettings struct {
|
||||
|
||||
@@ -69,6 +69,11 @@ var PERMISSION_MANAGE_JOBS *Permission
|
||||
var PERMISSION_CREATE_USER_ACCESS_TOKEN *Permission
|
||||
var PERMISSION_READ_USER_ACCESS_TOKEN *Permission
|
||||
var PERMISSION_REVOKE_USER_ACCESS_TOKEN *Permission
|
||||
var PERMISSION_CREATE_BOT *Permission
|
||||
var PERMISSION_READ_BOTS *Permission
|
||||
var PERMISSION_READ_OTHERS_BOTS *Permission
|
||||
var PERMISSION_MANAGE_BOTS *Permission
|
||||
var PERMISSION_MANAGE_OTHERS_BOTS *Permission
|
||||
|
||||
// General permission that encompasses all system admin functions
|
||||
// in the future this could be broken up to allow access to some
|
||||
@@ -396,6 +401,36 @@ func initializePermissions() {
|
||||
"authentication.permissions.revoke_user_access_token.description",
|
||||
PERMISSION_SCOPE_SYSTEM,
|
||||
}
|
||||
PERMISSION_CREATE_BOT = &Permission{
|
||||
"create_bot",
|
||||
"authentication.permissions.create_bot.name",
|
||||
"authentication.permissions.create_bot.description",
|
||||
PERMISSION_SCOPE_SYSTEM,
|
||||
}
|
||||
PERMISSION_READ_BOTS = &Permission{
|
||||
"read_bots",
|
||||
"authentication.permissions.read_bots.name",
|
||||
"authentication.permissions.read_bots.description",
|
||||
PERMISSION_SCOPE_SYSTEM,
|
||||
}
|
||||
PERMISSION_READ_OTHERS_BOTS = &Permission{
|
||||
"read_others_bots",
|
||||
"authentication.permissions.read_others_bots.name",
|
||||
"authentication.permissions.read_others_bots.description",
|
||||
PERMISSION_SCOPE_SYSTEM,
|
||||
}
|
||||
PERMISSION_MANAGE_BOTS = &Permission{
|
||||
"manage_bots",
|
||||
"authentication.permissions.manage_bots.name",
|
||||
"authentication.permissions.manage_bots.description",
|
||||
PERMISSION_SCOPE_SYSTEM,
|
||||
}
|
||||
PERMISSION_MANAGE_OTHERS_BOTS = &Permission{
|
||||
"manage_others_bots",
|
||||
"authentication.permissions.manage_others_bots.name",
|
||||
"authentication.permissions.manage_others_bots.description",
|
||||
PERMISSION_SCOPE_SYSTEM,
|
||||
}
|
||||
PERMISSION_MANAGE_JOBS = &Permission{
|
||||
"manage_jobs",
|
||||
"authentication.permisssions.manage_jobs.name",
|
||||
@@ -457,6 +492,11 @@ func initializePermissions() {
|
||||
PERMISSION_CREATE_USER_ACCESS_TOKEN,
|
||||
PERMISSION_READ_USER_ACCESS_TOKEN,
|
||||
PERMISSION_REVOKE_USER_ACCESS_TOKEN,
|
||||
PERMISSION_CREATE_BOT,
|
||||
PERMISSION_READ_BOTS,
|
||||
PERMISSION_READ_OTHERS_BOTS,
|
||||
PERMISSION_MANAGE_BOTS,
|
||||
PERMISSION_MANAGE_OTHERS_BOTS,
|
||||
PERMISSION_MANAGE_SYSTEM,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -345,6 +345,11 @@ func MakeDefaultRoles() map[string]*Role {
|
||||
PERMISSION_CREATE_USER_ACCESS_TOKEN.Id,
|
||||
PERMISSION_READ_USER_ACCESS_TOKEN.Id,
|
||||
PERMISSION_REVOKE_USER_ACCESS_TOKEN.Id,
|
||||
PERMISSION_CREATE_BOT.Id,
|
||||
PERMISSION_READ_BOTS.Id,
|
||||
PERMISSION_READ_OTHERS_BOTS.Id,
|
||||
PERMISSION_MANAGE_BOTS.Id,
|
||||
PERMISSION_MANAGE_OTHERS_BOTS.Id,
|
||||
PERMISSION_REMOVE_OTHERS_REACTIONS.Id,
|
||||
},
|
||||
roles[TEAM_USER_ROLE_ID].Permissions...,
|
||||
|
||||
@@ -80,6 +80,7 @@ type User struct {
|
||||
MfaActive bool `json:"mfa_active,omitempty"`
|
||||
MfaSecret string `json:"mfa_secret,omitempty"`
|
||||
LastActivityAt int64 `db:"-" json:"last_activity_at,omitempty"`
|
||||
IsBot bool `db:"-" json:"is_bot,omitempty"`
|
||||
}
|
||||
|
||||
type UserPatch struct {
|
||||
|
||||
16
model/user_count.go
Обычный файл
16
model/user_count.go
Обычный файл
@@ -0,0 +1,16 @@
|
||||
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
|
||||
// See License.txt for license information.
|
||||
|
||||
package model
|
||||
|
||||
// Options for counting users
|
||||
type UserCountOptions struct {
|
||||
// Should include users that are bots
|
||||
IncludeBotAccounts bool
|
||||
// Should include deleted users (of any type)
|
||||
IncludeDeleted bool
|
||||
// Exclude regular users
|
||||
ExcludeRegularUsers bool
|
||||
// Only include users on a specific team. "" for any team.
|
||||
TeamId string
|
||||
}
|
||||
Ссылка в новой задаче
Block a user