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

Adding support for transcripts, recording, AI summarization and meeting subscription to channels #377

Open
wants to merge 29 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 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
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
73 changes: 64 additions & 9 deletions server/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package main

import (
"fmt"
"strconv"
"strings"

"github.com/mattermost/mattermost-plugin-zoom/server/zoom"
Expand Down Expand Up @@ -31,6 +32,8 @@ const (
actionStart = "start"
actionDisconnect = "disconnect"
actionHelp = "help"
actionSubscribe = "subscribe"
actionUnsubscribe = "unsubscribe"
settings = "settings"
actionChannelSettings = "channel-settings"
channelSettingsActionList = "list"
Expand Down Expand Up @@ -72,7 +75,7 @@ func (p *Plugin) postCommandResponse(args *model.CommandArgs, text string) {
_ = p.API.SendEphemeralPost(args.UserId, post)
}

func (p *Plugin) parseCommand(rawCommand string) (cmd, action, topic string) {
func (p *Plugin) parseCommand(rawCommand string) (cmd, action, topic string, meetingID int) {
split := strings.Fields(rawCommand)
cmd = split[0]
if len(split) > 1 {
Expand All @@ -81,11 +84,14 @@ func (p *Plugin) parseCommand(rawCommand string) (cmd, action, topic string) {
if action == actionStart {
topic = strings.Join(split[2:], " ")
}
return cmd, action, topic
if len(split) > 2 && (action == actionSubscribe || action == actionUnsubscribe) {
meetingID, _ = strconv.Atoi(split[2])
}
return cmd, action, topic, meetingID
}

func (p *Plugin) executeCommand(c *plugin.Context, args *model.CommandArgs) (string, error) {
command, action, topic := p.parseCommand(args.Command)
command, action, topic, meetingID := p.parseCommand(args.Command)

if command != "/zoom" {
return fmt.Sprintf("Command '%s' is not /zoom. Please try again.", command), nil
Expand All @@ -104,6 +110,10 @@ func (p *Plugin) executeCommand(c *plugin.Context, args *model.CommandArgs) (str
switch action {
case actionConnect:
return p.runConnectCommand(user, args)
case actionSubscribe:
return p.runSubscribeCommand(user, args, meetingID)
jespino marked this conversation as resolved.
Show resolved Hide resolved
case actionUnsubscribe:
return p.runUnsubscribeCommand(user, args, meetingID)
case actionStart:
return p.runStartCommand(args, user, topic)
case actionDisconnect:
Expand Down Expand Up @@ -170,6 +180,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 @@ -187,20 +198,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 @@ -245,6 +256,45 @@ func (p *Plugin) runConnectCommand(user *model.User, extra *model.CommandArgs) (
return oauthMsg, 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) {
Copy link
Contributor

Choose a reason for hiding this comment

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

This is a product question - Who should have permissions to subscribe a channel to a meeting id? Probably good to DRY it up into its own method as well

Copy link
Member Author

Choose a reason for hiding this comment

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

I think a bit of repetition here is not that bad, anyway, I think the way of thinking about it, for me is, if you are able to publish a post, you will be able to do it anyway, but I'm ok if we only allow this to channel admins, for example.

return "You do not have permission to subscribe to this channel", nil
}

meeting, err := p.getMeeting(user, meetingID)
if err != nil {
return "Can not subscribe to meeting: meeting not found", errors.Wrap(err, "meeting not found")
}

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

if appErr := p.storeChannelForMeeting(meetingID, extra.ChannelId); 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
}

_, err := p.getMeeting(user, meetingID)
if err != nil {
return "Can not unsubscribe from meeting: meeting not accesible in zoom", errors.Wrap(err, "meeting not accesible in zoom")
}

if channelID, appErr := p.fetchChannelForMeeting(meetingID); appErr != nil || channelID == "" {
return "Can not unsubscribe from meeting: meeting not found", errors.New("meeting not found")
}
if appErr := p.deleteChannelForMeeting(meetingID); appErr != nil {
return "Can not unsubscribe from meeting: unable to delete the meeting subscription", errors.Wrap(appErr, "cannot unsubscribe from meeting")
}
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 @@ -260,7 +310,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 Down Expand Up @@ -424,9 +473,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, subscribe, unsubscribe, settings, channel-settings"
if canConnect {
available = "start, connect, disconnect, help, settings, channel-settings"
available = "start, connect, disconnect, help, subscribe, unsubscribe, settings, channel-settings"
}

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

subscribe := model.NewAutocompleteData("subscribe", "[meeting id]", "Subscribe this channel to a Zoom meeting")
zoom.AddCommand(subscribe)

unsubscribe := model.NewAutocompleteData("unsubscribe", "[meeting id]", "Unsubscribe this channel from a Zoom meeting")
zoom.AddCommand(unsubscribe)

// 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
44 changes: 31 additions & 13 deletions server/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,29 +180,30 @@ func (p *Plugin) startMeeting(action, userID, channelID, rootID string) {
}

var meetingID int
var meetingUUID string
var createMeetingErr error
createMeetingWithPMI := false
if action == usePersonalMeetingID {
createMeetingWithPMI = true
meetingID = zoomUser.Pmi

if meetingID <= 0 {
meetingID, createMeetingErr = p.createMeetingWithoutPMI(user, zoomUser, defaultMeetingTopic)
meetingID, meetingUUID, createMeetingErr = p.createMeetingWithoutPMI(user, zoomUser, defaultMeetingTopic)
if createMeetingErr != nil {
p.API.LogWarn("failed to create the meeting", "Error", createMeetingErr.Error())
return
}
p.sendEnableZoomPMISettingMessage(userID, channelID, rootID)
}
} else {
meetingID, createMeetingErr = p.createMeetingWithoutPMI(user, zoomUser, defaultMeetingTopic)
meetingID, meetingUUID, createMeetingErr = p.createMeetingWithoutPMI(user, zoomUser, defaultMeetingTopic)
if createMeetingErr != nil {
p.API.LogWarn("failed to create the meeting", "Error", createMeetingErr.Error())
return
}
}

if postMeetingErr := p.postMeeting(user, meetingID, channelID, rootID, defaultMeetingTopic); postMeetingErr != nil {
if postMeetingErr := p.postMeeting(user, meetingID, meetingUUID, channelID, rootID, defaultMeetingTopic); postMeetingErr != nil {
p.API.LogWarn("failed to post the meeting", "Error", postMeetingErr.Error())
return
}
Expand Down Expand Up @@ -405,14 +406,14 @@ func (p *Plugin) completeUserOAuthToZoom(w http.ResponseWriter, r *http.Request)
}
}

func (p *Plugin) postMeeting(creator *model.User, meetingID int, channelID string, rootID string, topic string) error {
func (p *Plugin) postMeeting(creator *model.User, meetingID int, meetingUUID string, channelID string, rootID string, topic string) error {
meetingURL := p.getMeetingURL(creator, meetingID)

if topic == "" {
topic = defaultMeetingTopic
}

if !p.API.HasPermissionToChannel(creator.Id, channelID, model.PermissionCreatePost) {
if p.botUserID != creator.Id && !p.API.HasPermissionToChannel(creator.Id, channelID, model.PermissionCreatePost) {
return errors.New("this channel is not accessible, you might not have permissions to write in this channel. Contact the administrator of this channel to find out if you have access permissions")
}

Expand All @@ -431,6 +432,7 @@ func (p *Plugin) postMeeting(creator *model.User, meetingID int, channelID strin
Props: map[string]interface{}{
"attachments": []*model.SlackAttachment{&slackAttachment},
"meeting_id": meetingID,
"meeting_uuid": meetingUUID,
"meeting_link": meetingURL,
"meeting_status": zoom.WebhookStatusStarted,
"meeting_personal": false,
Expand All @@ -445,7 +447,7 @@ func (p *Plugin) postMeeting(creator *model.User, meetingID int, channelID strin
return appErr
}

if appErr = p.storeMeetingPostID(meetingID, createdPost.Id); appErr != nil {
if appErr = p.storeMeetingPostID(meetingUUID, createdPost.Id); appErr != nil {
p.API.LogDebug("failed to store post id", "error", appErr)
}

Expand Down Expand Up @@ -658,20 +660,35 @@ func (p *Plugin) handleChannelPreference(w http.ResponseWriter, r *http.Request)
w.WriteHeader(http.StatusOK)
}

func (p *Plugin) createMeetingWithoutPMI(user *model.User, zoomUser *zoom.User, topic string) (int, error) {
func (p *Plugin) createMeetingWithoutPMI(user *model.User, zoomUser *zoom.User, topic string) (int, string, error) {
client, _, err := p.getActiveClient(user)
if err != nil {
p.API.LogWarn("Error getting the client", "Error", err.Error())
return -1, err
return -1, "", err
}

meeting, err := client.CreateMeeting(zoomUser, topic)
if err != nil {
p.API.LogWarn("Error creating the meeting", "Error", err.Error())
return -1, err
return -1, "", err
}

return meeting.ID, nil
return meeting.ID, meeting.UUID, nil
}

func (p *Plugin) getMeeting(user *model.User, meetingID int) (*zoom.Meeting, error) {
client, _, err := p.getActiveClient(user)
if err != nil {
p.API.LogWarn("could not get the active zoom client", "error", err.Error())
return nil, err
}

meeting, err := client.GetMeeting(meetingID)
if err != nil {
p.API.LogDebug("failed to get meeting")
return nil, err
}
return meeting, nil
}

func (p *Plugin) getMeetingURL(user *model.User, meetingID int) string {
Expand Down Expand Up @@ -991,6 +1008,7 @@ func (mv ZoomChannelSettingsMapValue) IsValid() error {

func (p *Plugin) handleMeetingCreation(channelID, rootID, topic string, user *model.User, zoomUser *zoom.User) (string, error) {
var meetingID int
var meetingUUID string
var createMeetingErr error
userPMISettingPref, err := p.getPMISettingData(user.Id)
if err != nil {
Expand All @@ -1007,20 +1025,20 @@ func (p *Plugin) handleMeetingCreation(channelID, rootID, topic string, user *mo
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 "", createMeetingErr
}
p.sendEnableZoomPMISettingMessage(user.Id, channelID, rootID)
}
default:
meetingID, createMeetingErr = p.createMeetingWithoutPMI(user, zoomUser, topic)
meetingID, meetingUUID, createMeetingErr = p.createMeetingWithoutPMI(user, zoomUser, topic)
if createMeetingErr != nil {
return "", createMeetingErr
}
}

if postMeetingErr := p.postMeeting(user, meetingID, channelID, rootID, topic); postMeetingErr != nil {
if postMeetingErr := p.postMeeting(user, meetingID, meetingUUID, channelID, rootID, topic); postMeetingErr != nil {
return "", createMeetingErr
}

Expand Down
4 changes: 2 additions & 2 deletions server/plugin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,10 @@ func TestPlugin(t *testing.T) {
meetingRequest := httptest.NewRequest("POST", "/api/v1/meetings", strings.NewReader("{\"channel_id\": \"thechannelid\"}"))
meetingRequest.Header.Add("Mattermost-User-Id", "theuserid")

endedPayload := `{"event": "meeting.ended", "payload": {"object": {"id": "234"}}}`
endedPayload := `{"event": "meeting.ended", "payload": {"object": {"id": "234", "uuid": "234"}}}`
validStoppedWebhookRequest := httptest.NewRequest("POST", "/webhook?secret=thewebhooksecret", strings.NewReader(endedPayload))

validStartedWebhookRequest := httptest.NewRequest("POST", "/webhook?secret=thewebhooksecret", strings.NewReader(`{"event": "meeting.started"}`))
validStartedWebhookRequest := httptest.NewRequest("POST", "/webhook?secret=thewebhooksecret", strings.NewReader(`{"event": "meeting.started", "payload": {"object": {"id": "234"}}}`))

noSecretWebhookRequest := httptest.NewRequest("POST", "/webhook", strings.NewReader(endedPayload))

Expand Down
43 changes: 33 additions & 10 deletions server/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (

const (
postMeetingKey = "post_meeting_"
meetingChannelKey = "meeting_channel_"
zoomStateKeyPrefix = "zoomuserstate"
zoomUserByMMID = "zoomtoken_"
zoomUserByZoomID = "zoomtokenbyzoomid_"
Expand Down Expand Up @@ -87,7 +88,6 @@ func (p *Plugin) fetchOAuthUserInfo(tokenKey, userID string) (*zoom.OAuthUserInf
func (p *Plugin) disconnectOAuthUser(userID string) error {
// according to the definition encoded would be nil
encoded, err := p.API.KVGet(zoomUserByMMID + userID)

if err != nil {
return errors.Wrap(err, "could not find OAuth user info")
}
Expand Down Expand Up @@ -142,29 +142,52 @@ func (p *Plugin) deleteUserState(userID string) *model.AppError {
return p.API.KVDelete(key)
}

func (p *Plugin) storeMeetingPostID(meetingID int, postID string) *model.AppError {
key := fmt.Sprintf("%v%v", postMeetingKey, meetingID)
func (p *Plugin) storeMeetingPostID(meetingUUID string, postID string) *model.AppError {
key := fmt.Sprintf("%v%v", postMeetingKey, meetingUUID)
b := []byte(postID)
return p.API.KVSetWithExpiry(key, b, meetingPostIDTTL)
}

func (p *Plugin) fetchMeetingPostID(meetingID string) (string, error) {
key := fmt.Sprintf("%v%v", postMeetingKey, meetingID)
var postID string
func (p *Plugin) fetchMeetingPostID(meetingUUID string) (string, error) {
key := fmt.Sprintf("%v%v", postMeetingKey, meetingUUID)
var postID []byte
if err := p.client.KV.Get(key, &postID); err != nil {
p.client.Log.Debug("Could not get meeting post from KVStore", "error", err.Error())
return "", err
}

if postID == "" {
if string(postID) == "" {
return "", errors.New("stored meeting post ID not found")
}

return postID, nil
return string(postID), nil
}

func (p *Plugin) storeChannelForMeeting(meetingID int, channelID string) error {
key := fmt.Sprintf("%v%v", meetingChannelKey, meetingID)
bytes := []byte(channelID)
_, err := p.client.KV.Set(key, bytes)
return err
}

func (p *Plugin) fetchChannelForMeeting(meetingID int) (string, *model.AppError) {
key := fmt.Sprintf("%v%v", meetingChannelKey, meetingID)
channelID, appErr := p.API.KVGet(key)
if appErr != nil {
p.API.LogDebug("Could not get channel meeting from KVStore", "error", appErr.Error())
return "", appErr
}

if channelID == nil {
p.API.LogWarn("Stored channel meeting not found")
return "", appErr
}

return string(channelID), nil
}

func (p *Plugin) deleteMeetingPostID(postID string) error {
key := fmt.Sprintf("%v%v", postMeetingKey, postID)
func (p *Plugin) deleteChannelForMeeting(meetingID int) error {
key := fmt.Sprintf("%v%v", meetingChannelKey, meetingID)
return p.client.KV.Delete(key)
}

Expand Down
Loading
Loading