Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[MM-44651] Implement MaxCallParticipants config setting #93

Merged
merged 12 commits into from
Jun 7, 2022
60 changes: 34 additions & 26 deletions plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"id": "com.mattermost.calls",
"name": "Calls",
"name": "Calls (Beta)",
"description": "Integrates real-time voice communication in Mattermost",
"homepage_url": "https://github.com/mattermost/mattermost-plugin-calls/",
"support_url": "https://github.com/mattermost/mattermost-plugin-calls/issues",
Expand All @@ -18,15 +18,9 @@
"bundle_path": "webapp/dist/main.js"
},
"settings_schema": {
"header": "",
"header": "Calls plugin enables voice calls with screensharing in channels. See [documentation](https://docs.mattermost.com/channels/make-calls.html) to learn more.",
"footer": "",
"settings": [{
"key": "ICEHostOverride",
"display_name": "ICE Host Override",
"type": "text",
"help_text": "The IP (or hostname) to be used as the host ICE candidate. If empty, it defaults to resolving via STUN.",
"default": ""
},
"settings": [
{
"key": "UDPServerPort",
"display_name": "RTC Server Port",
Expand All @@ -36,33 +30,47 @@
"placeholder": "8443"
},
{
"key": "RTCDServiceURL",
"display_name": "RTCD Service URL",
"key": "AllowEnableCalls",
"display_name": "Enable on Specific Channels",
"type": "bool",
"help_text": "When set to true, it allows channel admins to enable or disable calls in their channels. It also allows participants of DMs/GMs to enable or disable calls.",
"default": false
},
{
"key": "DefaultEnabled",
"display_name": "Enable on All Channels",
"type": "bool",
"help_text": "When set to true, calls will be possible in all channels where they are not explicitly disabled.",
"default": false
},
{
"key": "MaxCallParticipants",
"display_name": "Max Call Participants",
"type": "number",
"help_text": "The maximum number of participants that can join a call. If left empty, or set to 0, it means unlimited.",
"default": 0
},
{
"key": "ICEHostOverride",
"display_name": "ICE Host Override",
"type": "text",
"help_text": "The URL to a running RTCD service instance that should host the calls. When set (non empty) all calls will be handled by the external service.",
"placeholder": "https://rtcd.example.com"
"help_text": "(Optional) The IP (or hostname) to be used as the host ICE candidate. If empty, it defaults to resolving via STUN.",
"default": ""
},
{
"key": "ICEServers",
"display_name": "ICE Servers",
"type": "text",
"help_text": "A comma separated list of ICE servers URLs (STUN/TURN) to use.",
"help_text": "(Optional) A comma separated list of ICE servers URLs (STUN/TURN) to use.",
"default": "stun:stun.global.calls.mattermost.com:3478",
"placeholder": "stun:example.com:3478"
},
{
"key": "AllowEnableCalls",
"display_name": "Allow Enable Calls",
"type": "bool",
"help_text": "When set to true, it allows channel admins to enable or disable calls in their channels. It also allows participants of DMs/GMs to enable or disable calls.",
"default": false
},
{
"key": "DefaultEnabled",
"display_name": "Default Enabled Calls",
"type": "bool",
"help_text": "When set to true, calls will be possible in all channels where they are not explicitly disabled.",
"default": true
"key": "RTCDServiceURL",
"display_name": "RTCD Service URL",
"type": "text",
"help_text": "(Optional) The URL to a running RTCD service instance that should host the calls. When set (non empty) all calls will be handled by the external service.",
"placeholder": "https://rtcd.example.com"
}
]
}
Expand Down
31 changes: 22 additions & 9 deletions server/activate.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,6 @@ func (p *Plugin) OnActivate() error {
return fmt.Errorf("disabled by environment flag")
}

maxPart := os.Getenv("MM_CALLS_CLOUD_MAX_PARTICIPANTS")
if maxPart != "" {
if max, err := strconv.Atoi(maxPart); err == nil {
cloudMaxParticipants = max
} else {
p.LogError("activate", "MM_CALLS_CLOUD_MAX_PARTICIPANTS error during parsing:", err.Error())
}
}

pluginAPIClient := pluginapi.NewClient(p.API, p.Driver)
p.pluginAPI = pluginAPIClient

Expand All @@ -53,6 +44,28 @@ func (p *Plugin) OnActivate() error {
return err
}

// On Cloud installations we want calls enabled in all channels so we
// override it since the plugin's default is now false.
if isCloud(p.pluginAPI.System.GetLicense()) {
cfg.MaxCallParticipants = new(int)
*cfg.MaxCallParticipants = cloudMaxParticipantsDefault
if maxPart := os.Getenv("MM_CALLS_MAX_PARTICIPANTS"); maxPart != "" {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Discovered in QA: Do we not want to allow on-prem to use the MM_CALLS_MAX_PARTICIPANTS flag?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's more like we need it on Cloud, not so much on on-prem although given it's not possible to use common configuration overrides due to the plugin id issue I think it makes sense to allow this to be used on non-cloud installations as well. I can update 👍

if max, err := strconv.Atoi(maxPart); err == nil {
*cfg.MaxCallParticipants = max
} else {
p.LogError("activate", "failed to parse MM_CALLS_MAX_PARTICIPANTS", err.Error())
}
}

cfg.DefaultEnabled = new(bool)
*cfg.DefaultEnabled = true
if err := p.setConfiguration(cfg); err != nil {
err = fmt.Errorf("failed to set configuration: %w", err)
p.LogError(err.Error())
return err
}
}

if cfg.RTCDServiceURL != "" {
rtcdManager, err := p.newRTCDClientManager(cfg.RTCDServiceURL)
if err != nil {
Expand Down
8 changes: 3 additions & 5 deletions server/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -298,13 +298,11 @@ func (p *Plugin) handleConfig(w http.ResponseWriter) error {

type config struct {
clientConfig
SkuShortName string `json:"sku_short_name"`
CloudMaxParticipants int `json:"cloud_max_participants"`
SkuShortName string `json:"sku_short_name"`
}
ret := config{
clientConfig: p.getConfiguration().getClientConfig(),
SkuShortName: skuShortName,
CloudMaxParticipants: cloudMaxParticipants,
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I realize this is a breaking change but given it only affects Cloud I think we can safely make it now.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think... Good thing mobile isn't cut yet. :)

clientConfig: p.getConfiguration().getClientConfig(),
SkuShortName: skuShortName,
}

w.Header().Set("Content-Type", "application/json")
Expand Down
43 changes: 13 additions & 30 deletions server/cloud_limits.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,36 +7,13 @@ import (
"net/http"
)

// cloudMaxParticipants defaults to 8, can be overridden by setting the env variable
// MM_CALLS_CLOUD_MAX_PARTICIPANTS
var cloudMaxParticipants = 8
// cloudMaxParticipantsDefault is set to 8.
// The value used can be overridden by setting the MM_CLOUD_MAX_PARTICIPANTS env variable.

const maxAdminsToQueryForNotification = 25

// JoinAllowed returns true if the user is allowed to join the call, taking into
// account cloud limits
func (p *Plugin) joinAllowed(channel *model.Channel, state *channelState) (bool, error) {
// Rules are:
// On-prem: no limits to calls
// Cloud Starter: DMs 1-1 only
// Cloud Professional & Cloud Enterprise: DMs 1-1, GMs and Channel calls limited to 8 people.

license := p.pluginAPI.System.GetLicense()
if !isCloud(license) {
return true, nil
}

if isCloudStarter(license) {
return channel.Type == model.ChannelTypeDirect, nil
}

// we are cloud paid (starter or enterprise)
if len(state.Call.Users) >= cloudMaxParticipants {
return false, nil
}

return true, nil
}
const (
cloudMaxParticipantsDefault = 8
maxAdminsToQueryForNotification = 25
)

// handleCloudNotifyAdmins notifies the user's admin about upgrading for calls
func (p *Plugin) handleCloudNotifyAdmins(w http.ResponseWriter, r *http.Request) error {
Expand Down Expand Up @@ -68,12 +45,18 @@ func (p *Plugin) handleCloudNotifyAdmins(w http.ResponseWriter, r *http.Request)
return fmt.Errorf("no admins found")
}

maxParticipants := cloudMaxParticipantsDefault
cfg := p.getConfiguration()
if cfg != nil && cfg.MaxCallParticipants != nil {
maxParticipants = *cfg.MaxCallParticipants
}

separator := "\n\n---\n\n"
postType := "custom_cloud_trial_req"
message := fmt.Sprintf("@%s requested access to a free trial for Calls.", author.Username)
title := "Make calls in channels"
text := fmt.Sprintf("Start a call in a channel. You can include up to %d participants per call.%s[Upgrade now](https://customers.mattermost.com).",
cloudMaxParticipants, separator)
maxParticipants, separator)

attachments := []*model.SlackAttachment{
{
Expand Down
22 changes: 18 additions & 4 deletions server/configuration.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ type clientConfig struct {
AllowEnableCalls *bool
// When set to true, calls will be possible in all channels where they are not explicitly disabled.
DefaultEnabled *bool
// The maximum number of participants that can join a call. The zero value
// means unlimited.
MaxCallParticipants *int
}

type ICEServers []string
Expand Down Expand Up @@ -104,9 +107,10 @@ func (pr PortsRange) IsValid() error {

func (c *configuration) getClientConfig() clientConfig {
return clientConfig{
AllowEnableCalls: c.AllowEnableCalls,
DefaultEnabled: c.DefaultEnabled,
ICEServers: c.ICEServers,
AllowEnableCalls: c.AllowEnableCalls,
DefaultEnabled: c.DefaultEnabled,
ICEServers: c.ICEServers,
MaxCallParticipants: c.MaxCallParticipants,
}
}

Expand All @@ -119,7 +123,9 @@ func (c *configuration) SetDefaults() {
}
if c.DefaultEnabled == nil {
c.DefaultEnabled = new(bool)
*c.DefaultEnabled = true
}
if c.MaxCallParticipants == nil {
c.MaxCallParticipants = new(int)
}
}

Expand All @@ -132,6 +138,10 @@ func (c *configuration) IsValid() error {
return fmt.Errorf("UDPServerPort is not valid: %d is not in allowed range [1024, 49151]", *c.UDPServerPort)
}

if c.MaxCallParticipants == nil || *c.MaxCallParticipants < 0 {
return fmt.Errorf("MaxCallParticipants is not valid")
}

return nil
}

Expand Down Expand Up @@ -162,6 +172,10 @@ func (c *configuration) Clone() *configuration {
}
}

if c.MaxCallParticipants != nil {
cfg.MaxCallParticipants = model.NewInt(*c.MaxCallParticipants)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question for myself: why model.NewInt here but new(int) everywhere else?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Honestly, I feel like Hamlet now 💀

Part of me wants to avoid relying on model as much as possible but it's so tempting to save a line by using the util...

}

return &cfg
}

Expand Down
29 changes: 27 additions & 2 deletions server/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,9 +91,9 @@ func (p *Plugin) addUserSession(userID string, channel *model.Channel) (channelS
// Check for cloud limits -- needs to be done here to prevent a race condition
if allowed, err := p.joinAllowed(channel, state); !allowed {
if err != nil {
p.LogError("error checking for cloud limits", "error", err.Error())
p.LogError("joinAllowed failed", "error", err.Error())
}
return nil, fmt.Errorf("user cannot join because of cloud limits")
return nil, fmt.Errorf("user cannot join because of limits")
}

state.Call.Users[userID] = &userState{}
Expand Down Expand Up @@ -158,3 +158,28 @@ func (p *Plugin) removeUserSession(userID, channelID string) (channelState, chan

return currState, prevState, err
}

// JoinAllowed returns true if the user is allowed to join the call, taking into
// account cloud and configuration limits
func (p *Plugin) joinAllowed(channel *model.Channel, state *channelState) (bool, error) {
// Rules are:
// On-prem, Cloud Professional & Cloud Enterprise: DMs 1-1, GMs and Channel calls
// limited to cfg.MaxCallParticipants people.
// Cloud Starter: DMs 1-1 only

if cfg := p.getConfiguration(); cfg != nil && cfg.MaxCallParticipants != nil &&
*cfg.MaxCallParticipants != 0 && len(state.Call.Users) >= *cfg.MaxCallParticipants {
return false, nil
}

license := p.pluginAPI.System.GetLicense()
if !isCloud(license) {
return true, nil
}

if isCloudStarter(license) {
return channel.Type == model.ChannelTypeDirect, nil
}

return true, nil
}
4 changes: 2 additions & 2 deletions webapp/src/components/channel_call_toast/component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ interface Props {
startAt?: number,
pictures: string[],
profiles: UserProfile[],
isCloudLimitRestricted: boolean,
isLimitRestricted: boolean,
}

interface State {
Expand Down Expand Up @@ -58,7 +58,7 @@ export default class ChannelCallToast extends React.PureComponent<Props, State>
}

render() {
if (!this.props.hasCall || this.state.hidden || this.props.isCloudLimitRestricted) {
if (!this.props.hasCall || this.state.hidden || this.props.isLimitRestricted) {
return null;
}

Expand Down
4 changes: 2 additions & 2 deletions webapp/src/components/channel_call_toast/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
voiceConnectedProfilesInChannel,
connectedChannelID,
voiceChannelCallStartAt,
isCloudLimitRestricted,
isLimitRestricted,
} from 'selectors';

import ChannelCallToast from './component';
Expand Down Expand Up @@ -39,7 +39,7 @@ const mapStateToProps = (state) => {
startAt: voiceChannelCallStartAt(state, currentID),
pictures,
profiles,
isCloudLimitRestricted: isCloudLimitRestricted(state),
isLimitRestricted: isLimitRestricted(state),
};
};

Expand Down
Loading