Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
db76929
Adding support for transcripts, recording and AI summarization
jespino May 9, 2024
a305b43
WIP
jespino May 10, 2024
dd47bbf
Adding chat support
jespino May 10, 2024
12815af
WIP
jespino May 10, 2024
161b660
Adding support for subscription of meetings to channels
jespino May 15, 2024
467399e
Removing debug log messages
jespino May 15, 2024
e448589
Fixing tests
jespino May 15, 2024
df60c28
fixing linter errors
jespino May 15, 2024
bc696fd
Fixing linter errors
jespino May 15, 2024
513006a
Addressing PR review comments
jespino May 16, 2024
8b30a5d
Updating webhooks subscriptions needed in the documentation
jespino May 16, 2024
a810064
Addressing PR review comments
jespino May 16, 2024
9ae260c
Addressing PR review comments
jespino May 16, 2024
42bfc3a
Avoid subscriptions to personal meetings
jespino May 16, 2024
4548448
Merge remote-tracking branch 'origin/master' into transcripts-recordi…
jespino May 16, 2024
4f6233f
Migrating to typescript
jespino May 16, 2024
0cc74d9
Migrating to typescript
jespino May 16, 2024
bf0599d
Fixing linter
jespino May 17, 2024
7777670
Adding tests for transcript and chat handlers
jespino May 17, 2024
b67ed82
Fixing some linter errors
jespino May 17, 2024
3b4d73d
Addressing some PR review comments
jespino May 23, 2024
5efacfd
Fixing types
jespino May 24, 2024
18f9706
Merge remote-tracking branch 'origin/master' into transcripts-recordi…
jespino Jul 2, 2024
0702a63
Merge remote-tracking branch 'origin/master' into transcripts-recordi…
jespino Jul 12, 2024
dd108cf
Adding the meeting UUID
jespino Jul 12, 2024
e6b0a50
Merging master and fixed problems related to the merge
jespino Jul 12, 2024
d75b8a5
Fixing a crash on subscription
jespino Jul 12, 2024
5c9a13a
Fixing the alteration on the length after receiving transcriptions/re…
jespino Jul 12, 2024
30d8b15
Merge remote-tracking branch 'origin/master' into transcripts-recordi…
jespino Sep 26, 2024
0280fc4
Merge remote-tracking branch 'origin/master' into transcripts-recordi…
jespino Jan 11, 2025
d3067af
Fixing linter error
jespino Jan 11, 2025
fc9d542
fix: Add missing mock expectation for KVGet in test case
jespino Jan 11, 2025
191ef21
fix: Add missing mock expectation for GetUser in webhook test
jespino Jan 11, 2025
0af0204
fix: Add missing mock expectation for bot user's Zoom token
jespino Jan 11, 2025
d3f70c4
fix: Add missing KVSetWithExpiry mock for meeting post ID test
jespino Jan 11, 2025
70f1af5
fix: Add missing mock expectation for KVSetWithExpiry in test
jespino Jan 11, 2025
5fe64f0
test: Add mock expectation for PublishWebSocketEvent in test case
jespino Jan 11, 2025
9dc2a3c
fix: Initialize plugin client in webhook test to resolve nil pointer …
jespino Jan 11, 2025
c90d235
fix: Initialize plugin client in TestWebhookHandleRecordingCompleted …
jespino Jan 11, 2025
fb6ae2f
Fixing tests
jespino Jan 11, 2025
e9ea52a
Fixing linter error
jespino Jan 11, 2025
a35591e
test: Add tests for `handleMeetingStarted` webhook functionality
jespino Jan 11, 2025
d2424db
Merge branch 'master' into MM-67409
avasconcelos114 Feb 23, 2026
8043503
WIP: Unified subscriptions under the same nested command
avasconcelos114 Feb 25, 2026
4216b5e
Fixing issues with missing implementation
avasconcelos114 Feb 25, 2026
33464b4
Ensuring recurring meetings create a new post on each iteration of th…
avasconcelos114 Feb 26, 2026
59e778a
Refactoring for readability and final bug fixes
avasconcelos114 Feb 26, 2026
c5ae8e9
Adding domain check so we only trust the ones configured in the plugi…
avasconcelos114 Feb 26, 2026
fe4ecbc
Applying fixes for AI review findings
avasconcelos114 Feb 27, 2026
f52c56b
Fixing tests to match scheme validation
avasconcelos114 Feb 27, 2026
0c6f2a8
Applying fixes for PR review
avasconcelos114 Mar 6, 2026
3a530ea
Fixing URL validation so zoom subdomains arent blocked
avasconcelos114 Mar 6, 2026
b01c859
Fixing other round of reviews
avasconcelos114 Mar 6, 2026
c347528
Added recording password setting
avasconcelos114 Mar 6, 2026
97c2071
Improving UX, clarity of password setting, and error handling
avasconcelos114 Mar 6, 2026
7284ab7
Handling cases of empty UUID in webhook payload
avasconcelos114 Mar 6, 2026
d06974b
Fixing linter error
avasconcelos114 Mar 6, 2026
115931c
Added handling for lost permissions and permission checks on subscrip…
avasconcelos114 Mar 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,15 @@
"regenerate_help_text": "",
"placeholder": "",
"default": false
},
{
"key": "EnablePostingRecordingPassword",
"display_name": "Enable Posting Recording Password:",
"type": "bool",
"help_text": "When enabled, the recording password is posted to the channel alongside the recording link. This makes the password visible to all channel members and persisted in channel history and compliance exports. Only enable for channels where all members are trusted to access the recording.",
"regenerate_help_text": "",
"placeholder": "",
"default": false
}
]
}
Expand Down
177 changes: 164 additions & 13 deletions server/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package main
import (
"fmt"
"net/url"
"strconv"
"strings"

"github.com/mattermost/mattermost-plugin-zoom/server/zoom"
Expand All @@ -24,17 +25,24 @@ const (
settingHelpText = `* |/zoom settings| - Update your preferences`
channelPreferenceHelpText = `* |/zoom channel-settings| - Update your current channel preference`
listChannelPreferenceHelpText = `* |/zoom channel-settings list| - List all channel preferences`
alreadyConnectedText = "Already connected"
zoomPreferenceCategory = "plugin:zoom"
zoomPMISettingName = "use-pmi"
zoomPMISettingValueAsk = "ask"
subscriptionHelpText = `* |/zoom subscription add [meetingID]| - Subscribe this channel to a Zoom meeting
* |/zoom subscription remove [meetingID]| - Unsubscribe this channel from a Zoom meeting
* |/zoom subscription list| - List all meeting subscriptions`
alreadyConnectedText = "Already connected"
zoomPreferenceCategory = "plugin:zoom"
zoomPMISettingName = "use-pmi"
zoomPMISettingValueAsk = "ask"
)

const (
actionConnect = "connect"
actionStart = "start"
actionDisconnect = "disconnect"
actionHelp = "help"
actionSubscription = "subscription"
subscriptionActionAdd = "add"
subscriptionActionRemove = "remove"
subscriptionActionList = "list"
settings = "settings"
actionChannelSettings = "channel-settings"
channelSettingsActionList = "list"
Expand All @@ -52,9 +60,9 @@ func (p *Plugin) getCommand() (*model.Command, error) {

canConnect := !p.configuration.AccountLevelApp

autoCompleteDesc := "Available commands: start, help, settings, channel-settings"
autoCompleteDesc := "Available commands: start, help, subscription, settings, channel-settings"
if canConnect {
autoCompleteDesc = "Available commands: start, connect, disconnect, help, settings, channel-settings"
autoCompleteDesc = "Available commands: start, connect, disconnect, help, subscription, settings, channel-settings"
}

return &model.Command{
Expand Down Expand Up @@ -108,6 +116,8 @@ func (p *Plugin) executeCommand(c *plugin.Context, args *model.CommandArgs) (str
switch action {
case actionConnect:
return p.runConnectCommand(user, args)
case actionSubscription:
return p.runSubscriptionCommand(args, strings.Fields(args.Command)[2:], user)
case actionStart:
return p.runStartCommand(args, user, topic)
case actionDisconnect:
Expand Down Expand Up @@ -174,6 +184,7 @@ func (p *Plugin) runStartCommand(args *model.CommandArgs, user *model.User, topi
}

var meetingID int
var meetingUUID string
var createMeetingErr error

userPMISettingPref, err := p.getPMISettingData(user.Id)
Expand All @@ -191,20 +202,20 @@ func (p *Plugin) runStartCommand(args *model.CommandArgs, user *model.User, topi
meetingID = zoomUser.Pmi

if meetingID <= 0 {
meetingID, createMeetingErr = p.createMeetingWithoutPMI(user, zoomUser, topic)
meetingID, meetingUUID, createMeetingErr = p.createMeetingWithoutPMI(user, zoomUser, topic)
if createMeetingErr != nil {
return "", errors.Wrap(createMeetingErr, "failed to create the meeting")
}
p.sendEnableZoomPMISettingMessage(user.Id, args.ChannelId, args.RootId)
}
default:
meetingID, createMeetingErr = p.createMeetingWithoutPMI(user, zoomUser, topic)
meetingID, meetingUUID, createMeetingErr = p.createMeetingWithoutPMI(user, zoomUser, topic)
if createMeetingErr != nil {
return "", errors.Wrap(createMeetingErr, "failed to create the meeting")
}
}

if postMeetingErr := p.postMeeting(user, meetingID, args.ChannelId, args.RootId, topic); postMeetingErr != nil {
if postMeetingErr := p.postMeeting(user, meetingID, meetingUUID, args.ChannelId, args.RootId, topic); postMeetingErr != nil {
return "", postMeetingErr
}

Expand Down Expand Up @@ -249,6 +260,138 @@ func (p *Plugin) runConnectCommand(user *model.User, extra *model.CommandArgs) (
return oauthMsg, nil
}

// subscriptionMeetingIDActions maps subscription actions that require a meeting ID
// to their handler functions. Add new actions here to extend the command.
var subscriptionMeetingIDActions = map[string]func(p *Plugin, user *model.User, args *model.CommandArgs, meetingID int) (string, error){
subscriptionActionAdd: (*Plugin).runSubscribeCommand,
subscriptionActionRemove: (*Plugin).runUnsubscribeCommand,
}

func (p *Plugin) runSubscriptionCommand(args *model.CommandArgs, params []string, user *model.User) (string, error) {
if len(params) == 0 {
return "Please specify a subscription action: `add`, `remove`, or `list`.\nUsage: `/zoom subscription [action] [meetingID]`", nil
}

action := params[0]

if action == subscriptionActionList {
return p.runSubscriptionListCommand(args)
}

handler, ok := subscriptionMeetingIDActions[action]
if !ok {
return fmt.Sprintf("Unknown subscription action: `%s`. Available actions: `add`, `remove`, `list`.", action), nil
}

if len(params) < 2 {
return fmt.Sprintf("Please specify a meeting ID. Usage: `/zoom subscription %s [meetingID]`", action), nil
}
meetingID, err := strconv.Atoi(strings.Join(params[1:], ""))
if err != nil {
return "Invalid meeting ID. Please provide a numeric meeting ID.", nil
}

return handler(p, user, args, meetingID)
}

func (p *Plugin) runSubscriptionListCommand(args *model.CommandArgs) (string, error) {
if !p.API.HasPermissionToChannel(args.UserId, args.ChannelId, model.PermissionCreatePost) {
return "You do not have permission to view subscriptions in this channel.", nil
}

subs, err := p.listAllMeetingSubscriptions(args.UserId)
if err != nil {
p.client.Log.Error("Unable to list meeting subscriptions", "Error", err.Error())
return "Unable to list meeting subscriptions.", nil
}

if len(subs) == 0 {
return "No meeting subscriptions found.", nil
}

var sb strings.Builder
sb.WriteString("#### Meeting Subscriptions\n\n")
sb.WriteString("| Meeting ID | Channel |\n")
sb.WriteString("| :--- | :--- |\n")

for meetingID, channelID := range subs {
channel, appErr := p.client.Channel.Get(channelID)
if appErr != nil {
p.client.Log.Error("Unable to get channel for subscription list", "ChannelID", channelID, "Error", appErr.Error())
sb.WriteString(fmt.Sprintf("| %s | (unknown channel %s) |\n", meetingID, channelID))
continue
}
sb.WriteString(fmt.Sprintf("| %s | ~%s |\n", meetingID, channel.Name))
}

return sb.String(), nil
}

func (p *Plugin) runSubscribeCommand(user *model.User, extra *model.CommandArgs, meetingID int) (string, error) {
if !p.API.HasPermissionToChannel(user.Id, extra.ChannelId, model.PermissionCreatePost) {
return "You do not have permission to subscribe to this channel", nil
}

meeting, err := p.getMeeting(user, meetingID)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Since the plugin can operate in account-level OAuth mode (where I think getActiveClient uses a superuser token), getMeeting() will succeed for any meeting in the Zoom account - not just meetings owned by the user running the command. I think this means any Mattermost user could subscribe a channel to someone else's meeting by knowing its numeric ID.

The concern goes beyond "meeting started" notifications: when recordings and transcripts complete, handleRecordingCompleted and handleTranscriptCompleted post them as replies to the original meeting post - so the subscribing user would also receive the video recording (with password if enabled), chat history, and transcript of a meeting they weren't part of.

Would it make sense to check that meeting.HostID matches the Zoom user linked to the Mattermost user (or that the user is a system admin) before allowing the subscription?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

That's a great point, I think it will be good to start out conservative with allowing only users who are the hosts and sys admins to create subscriptions, i'll make the changes needed

if err != nil {
return "Cannot subscribe to meeting: meeting not found", errors.Wrap(err, "meeting not found")
}

if meeting.Type == zoom.MeetingTypePersonal {
return "Cannot subscribe to personal meeting", nil
}

if !user.IsSystemAdmin() {
zoomUser, authErr := p.authenticateAndFetchZoomUser(user)
if authErr != nil {
if appErr := p.storeOAuthUserState(user.Id, extra.ChannelId, false); appErr != nil {
p.API.LogWarn("failed to store user state")
}
return authErr.Message, authErr.Err
}
if meeting.HostID != zoomUser.ID {
return "You can only subscribe to meetings you host. Contact a system admin to subscribe to other meetings.", nil
}
}

if appErr := p.storeSubscriptionForMeeting(meetingID, extra.ChannelId, user.Id); appErr != nil {
return "", errors.Wrap(appErr, "cannot subscribe to meeting")
}

return "Channel subscribed to meeting.", nil
}

func (p *Plugin) runUnsubscribeCommand(user *model.User, extra *model.CommandArgs, meetingID int) (string, error) {
if !p.API.HasPermissionToChannel(user.Id, extra.ChannelId, model.PermissionCreatePost) {
return "You do not have permission to unsubscribe from this channel", nil
}

entry, appErr := p.getMeetingChannelEntry(meetingID)
if appErr != nil {
return "Unable to load the meeting subscription.", errors.Wrap(appErr, "cannot fetch meeting subscription")
}
if entry == nil || !entry.IsSubscription {
return "No subscription found for this meeting.", nil
}
if entry.ChannelID != extra.ChannelId {
return "This meeting is subscribed to a different channel.", nil
}

if entry.CreatedBy != user.Id && !user.IsSystemAdmin() {
return "You can only remove subscriptions you created.", nil
}

if appErr := p.deleteChannelForMeeting(meetingID); appErr != nil {
return "Unable to delete the meeting subscription.", errors.Wrap(appErr, "cannot unsubscribe from meeting")
}

if err := p.removeFromSubscriptionIndex(entry.CreatedBy, meetingID); err != nil {
p.API.LogWarn("failed to update subscription index on removal", "error", err.Error())
}

return "Channel unsubscribed from meeting.", nil
}

// runDisconnectCommand runs command to disconnect from Zoom. Will fail if user cannot connect.
func (p *Plugin) runDisconnectCommand(user *model.User) (string, error) {
if !p.canConnect(user) {
Expand All @@ -264,7 +407,6 @@ func (p *Plugin) runDisconnectCommand(user *model.User) (string, error) {
}

err := p.disconnectOAuthUser(user.Id)

if err != nil {
return "Could not disconnect OAuth from Zoom, " + err.Error(), nil
}
Expand All @@ -276,7 +418,7 @@ func (p *Plugin) runDisconnectCommand(user *model.User) (string, error) {

// runHelpCommand runs command to display help text.
func (p *Plugin) runHelpCommand(user *model.User) (string, error) {
text := starterText + strings.ReplaceAll(helpText+"\n"+settingHelpText, "|", "`")
text := starterText + strings.ReplaceAll(helpText+"\n"+settingHelpText+"\n"+subscriptionHelpText, "|", "`")
if p.API.HasPermissionTo(user.Id, model.PermissionManageSystem) {
text += "\n" + strings.ReplaceAll(channelPreferenceHelpText+"\n"+listChannelPreferenceHelpText, "|", "`")
}
Expand Down Expand Up @@ -430,9 +572,9 @@ func (p *Plugin) runChannelSettingsListCommand(args *model.CommandArgs) (string,
func (p *Plugin) getAutocompleteData() *model.AutocompleteData {
canConnect := !p.configuration.AccountLevelApp

available := "start, help, settings, channel-settings"
available := "start, help, subscription, settings, channel-settings"
if canConnect {
available = "start, connect, disconnect, help, settings, channel-settings"
available = "start, connect, disconnect, help, subscription, settings, channel-settings"
}

zoom := model.NewAutocompleteData("zoom", "[command]", fmt.Sprintf("Available commands: %s", available))
Expand All @@ -451,6 +593,15 @@ func (p *Plugin) getAutocompleteData() *model.AutocompleteData {
setting := model.NewAutocompleteData("settings", "", "Update your meeting ID preferences")
zoom.AddCommand(setting)

subscription := model.NewAutocompleteData("subscription", "[action]", "Manage meeting subscriptions")
subAdd := model.NewAutocompleteData("add", "[meeting id]", "Subscribe this channel to a Zoom meeting")
subRemove := model.NewAutocompleteData("remove", "[meeting id]", "Unsubscribe this channel from a Zoom meeting")
subList := model.NewAutocompleteData("list", "", "List all meeting subscriptions and their channels")
subscription.AddCommand(subAdd)
subscription.AddCommand(subRemove)
subscription.AddCommand(subList)
zoom.AddCommand(subscription)

// channel-settings to update channel preferences
channelSettings := model.NewAutocompleteData("channel-settings", "", "Update current channel preference")
channelSettingsList := model.NewAutocompleteData("list", "", "List all the channel preferences")
Expand Down
3 changes: 3 additions & 0 deletions server/configuration.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ type configuration struct {
// RestrictMeetingCreation allows the admin to by default restrict Zoom meetings to only private channels.
// The admin can also edit each channel's behavior with the `/zoom channel-settings` command
RestrictMeetingCreation bool

// EnablePostingRecordingPassword allows the admin to enable posting the recording password to the channel when the recording is posted.
EnablePostingRecordingPassword bool
}

// Clone shallow copies the configuration. Your implementation may require a deep copy if
Expand Down
Loading
Loading