diff --git a/plugin.json b/plugin.json index 9faac08e..26adb1d7 100644 --- a/plugin.json +++ b/plugin.json @@ -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 } ] } diff --git a/server/command.go b/server/command.go index 51c3f2db..cd5c2cce 100644 --- a/server/command.go +++ b/server/command.go @@ -6,6 +6,7 @@ package main import ( "fmt" "net/url" + "strconv" "strings" "github.com/mattermost/mattermost-plugin-zoom/server/zoom" @@ -24,10 +25,13 @@ 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 ( @@ -35,6 +39,10 @@ const ( actionStart = "start" actionDisconnect = "disconnect" actionHelp = "help" + actionSubscription = "subscription" + subscriptionActionAdd = "add" + subscriptionActionRemove = "remove" + subscriptionActionList = "list" settings = "settings" actionChannelSettings = "channel-settings" channelSettingsActionList = "list" @@ -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{ @@ -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: @@ -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) @@ -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 } @@ -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) + 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) { @@ -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 } @@ -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, "|", "`") } @@ -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)) @@ -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") diff --git a/server/configuration.go b/server/configuration.go index 90aac514..51f749a3 100644 --- a/server/configuration.go +++ b/server/configuration.go @@ -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 diff --git a/server/http.go b/server/http.go index 303ca49d..41896a88 100644 --- a/server/http.go +++ b/server/http.go @@ -220,6 +220,7 @@ func (p *Plugin) startMeeting(action, userID, channelID, rootID string) { } var meetingID int + var meetingUUID string var createMeetingErr error createMeetingWithPMI := false if action == usePersonalMeetingID { @@ -227,7 +228,7 @@ func (p *Plugin) startMeeting(action, userID, channelID, rootID string) { 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 @@ -235,14 +236,14 @@ func (p *Plugin) startMeeting(action, userID, channelID, rootID string) { 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 } @@ -456,14 +457,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") } @@ -482,6 +483,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, @@ -496,8 +498,14 @@ func (p *Plugin) postMeeting(creator *model.User, meetingID int, channelID strin return appErr } - if appErr = p.storeMeetingPostID(meetingID, createdPost.Id); appErr != nil { - p.API.LogDebug("failed to store post id", "error", appErr) + if meetingUUID != "" { + if appErr = p.storeMeetingPostID(meetingUUID, createdPost.Id); appErr != nil { + p.API.LogWarn("failed to store meeting post ID", "error", appErr.Error()) + } + } + + if err := p.storeChannelForMeeting(meetingID, channelID); err != nil { + p.API.LogWarn("failed to store channel for meeting", "error", err.Error()) } p.client.Frontend.PublishWebSocketEvent( @@ -725,20 +733,34 @@ 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 { + return nil, err + } + return meeting, nil } func (p *Plugin) getMeetingURL(user *model.User, meetingID int) string { @@ -924,13 +946,21 @@ func (p *Plugin) completeCompliance(payload zoom.DeauthorizationPayload) error { } // parseOAuthUserState parses the user ID and the channel ID from the given OAuth user state. +// Expected format: "{nonce}_{userID}_{channelID}_{true|false}" func parseOAuthUserState(state string) (userID, channelID string, justConnect bool, err error) { stateComponents := strings.Split(state, "_") if len(stateComponents) != zoomOAuthUserStateLength { - return "", "", false, errors.New("invalid OAuth user state") + return "", "", false, errors.Errorf( + "invalid OAuth user state: expected %d components, got %d (state length=%d)", + zoomOAuthUserStateLength, len(stateComponents), len(state), + ) } - return stateComponents[1], stateComponents[2], stateComponents[3] == trueString, nil + userID = stateComponents[1] + channelID = stateComponents[2] + justConnect = stateComponents[3] == trueString + + return userID, channelID, justConnect, nil } func (p *Plugin) sendUserSettingForm(userID, channelID, rootID string) error { @@ -1062,6 +1092,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 { @@ -1078,21 +1109,21 @@ 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 { - return "", createMeetingErr + if postMeetingErr := p.postMeeting(user, meetingID, meetingUUID, channelID, rootID, topic); postMeetingErr != nil { + return "", postMeetingErr } p.trackMeetingStart(user.Id, telemetryStartSourceCommand) diff --git a/server/plugin.go b/server/plugin.go index ee49ba21..50e6c9f6 100644 --- a/server/plugin.go +++ b/server/plugin.go @@ -5,9 +5,11 @@ package main import ( "fmt" + "net/http" "os" "path/filepath" "sync" + "time" "github.com/pkg/errors" "golang.org/x/oauth2" @@ -29,8 +31,14 @@ const ( falseString = "false" zoomProviderName = "Zoom" + + defaultDownloadTimeout = 5 * time.Minute ) +var defaultDownloadClient = &http.Client{ + Timeout: defaultDownloadTimeout, +} + type Plugin struct { plugin.MattermostPlugin @@ -50,11 +58,16 @@ type Plugin struct { telemetryClient telemetry.Client tracker telemetry.Tracker + + // downloadClient is the HTTP client used for downloading files from Zoom. + // Initialized in OnActivate; tests may override it before exercising handlers. + downloadClient *http.Client } // OnActivate checks if the configurations is valid and ensures the bot account exists func (p *Plugin) OnActivate() error { p.client = pluginapi.NewClient(p.API, p.Driver) + p.downloadClient = defaultDownloadClient config := p.getConfiguration() if err := config.IsValid(p.isCloudLicense()); err != nil { diff --git a/server/plugin_test.go b/server/plugin_test.go index a6b275ef..334e413a 100644 --- a/server/plugin_test.go +++ b/server/plugin_test.go @@ -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", "uuid": "234"}}}`)) noSecretWebhookRequest := httptest.NewRequest("POST", "/webhook", strings.NewReader(endedPayload)) @@ -131,6 +131,13 @@ func TestPlugin(t *testing.T) { api.On("KVGet", "mmi_botid").Return([]byte(botUserID), nil) api.On("KVGet", "zoomtoken_theuserid").Return(userInfo, nil) + meetingEntry, _ := json.Marshal(meetingChannelEntry{ChannelID: "thechannelid"}) + api.On("KVGet", "meeting_channel_234").Return(meetingEntry, nil) + api.On("KVGet", "zoomtoken_"+botUserID).Return(userInfo, nil) + api.On("GetUser", botUserID).Return(&model.User{ + Id: botUserID, + }, nil) + api.On("PublishWebSocketEvent", "meeting_started", map[string]interface{}{"meeting_url": "https://zoom.us/j/234"}, &model.WebsocketBroadcast{UserId: botUserID}).Return() api.On("SendEphemeralPost", "theuserid", mock.AnythingOfType("*model.Post")).Return(nil) @@ -163,6 +170,8 @@ func TestPlugin(t *testing.T) { api.On("KVSetWithOptions", "mutex_mmi_bot_ensure", mock.AnythingOfType("[]uint8"), model.PluginKVSetOptions{Atomic: true, OldValue: []uint8(nil), ExpireInSeconds: 15}).Return(true, nil) api.On("KVSetWithOptions", "mutex_mmi_bot_ensure", []byte(nil), model.PluginKVSetOptions{ExpireInSeconds: 0}).Return(true, nil) api.On("KVSetWithOptions", "post_meeting_234", []byte(nil), model.PluginKVSetOptions{ExpireInSeconds: 0}).Return(true, nil) + api.On("KVGet", mock.MatchedBy(func(key string) bool { return strings.HasPrefix(key, meetingChannelKey) })).Return(nil, (*model.AppError)(nil)).Maybe() + api.On("KVSetWithExpiry", mock.MatchedBy(func(key string) bool { return strings.HasPrefix(key, meetingChannelKey) }), mock.AnythingOfType("[]uint8"), int64(adHocMeetingChannelTTL)).Return(nil).Maybe() api.On("EnsureBotUser", &model.Bot{ Username: botUserName, @@ -176,8 +185,7 @@ func TestPlugin(t *testing.T) { api.On("KVDelete", fmt.Sprintf("%v%v", postMeetingKey, 234)).Return(nil) - api.On("LogWarn", mock.AnythingOfType("string")).Return() - api.On("LogDebug", mock.AnythingOfType("string"), mock.AnythingOfType("string"), mock.AnythingOfType("string")).Return() + allowFlexibleLogging(api) path, err := filepath.Abs("..") require.Nil(t, err) diff --git a/server/store.go b/server/store.go index 6d0d9b56..75298b3f 100644 --- a/server/store.go +++ b/server/store.go @@ -6,6 +6,8 @@ package main import ( "encoding/json" "fmt" + "net/http" + "net/url" "github.com/mattermost/mattermost/server/public/model" "github.com/pkg/errors" @@ -16,6 +18,7 @@ import ( const ( postMeetingKey = "post_meeting_" + meetingChannelKey = "meeting_channel_" zoomStateKeyPrefix = "zoomuserstate" zoomUserByMMID = "zoomtoken_" zoomUserByZoomID = "zoomtokenbyzoomid_" @@ -87,7 +90,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") } @@ -142,14 +144,20 @@ 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) +// meetingPostKey returns a KV-safe key for a given Zoom meeting UUID. +// Zoom UUIDs can contain '/' and '=' which may cause issues in KV keys. +func meetingPostKey(meetingUUID string) string { + return postMeetingKey + url.PathEscape(meetingUUID) +} + +func (p *Plugin) storeMeetingPostID(meetingUUID string, postID string) *model.AppError { + key := meetingPostKey(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) +func (p *Plugin) fetchMeetingPostID(meetingUUID string) (string, error) { + key := meetingPostKey(meetingUUID) var postIDData []byte if err := p.client.KV.Get(key, &postIDData); err != nil { p.client.Log.Debug("Could not get meeting post from KVStore", "error", err.Error()) @@ -163,11 +171,237 @@ func (p *Plugin) fetchMeetingPostID(meetingID string) (string, error) { return string(postIDData), nil } -func (p *Plugin) deleteMeetingPostID(postID string) error { - key := fmt.Sprintf("%v%v", postMeetingKey, postID) +// meetingChannelEntry stores metadata about a meeting-to-channel mapping. +type meetingChannelEntry struct { + ChannelID string `json:"channel_id"` + IsSubscription bool `json:"is_subscription"` + CreatedBy string `json:"created_by"` +} + +// Ad-hoc meeting channel entries expire after 24 hours. This must be long +// enough to cover the full meeting duration (which can be many hours) plus +// the post-meeting window for recording/transcript webhooks to arrive. +const adHocMeetingChannelTTL = 60 * 60 * 24 + +func meetingChannelKVKey(meetingID int) string { + return fmt.Sprintf("%v%v", meetingChannelKey, meetingID) +} + +func (p *Plugin) storeSubscriptionForMeeting(meetingID int, channelID, userID string) error { + existing, appErr := p.getMeetingChannelEntry(meetingID) + if appErr != nil { + return appErr + } + if existing != nil && existing.IsSubscription { + if existing.ChannelID == channelID && existing.CreatedBy == userID { + return nil + } + return errors.New("meeting already has an existing subscription") + } + + entry := meetingChannelEntry{ + ChannelID: channelID, + IsSubscription: true, + CreatedBy: userID, + } + data, err := json.Marshal(entry) + if err != nil { + return err + } + if appErr := p.API.KVSet(meetingChannelKVKey(meetingID), data); appErr != nil { + return appErr + } + + if err := p.addToSubscriptionIndex(userID, meetingID); err != nil { + p.API.LogWarn("failed to update subscription index, rolling back", + "meeting_id", meetingID, + "error", err.Error(), + ) + _ = p.deleteChannelForMeeting(meetingID) + return errors.Wrap(err, "failed to update subscription index") + } + + return nil +} + +func (p *Plugin) storeChannelForMeeting(meetingID int, channelID string) error { + key := meetingChannelKVKey(meetingID) + + existing, appErr := p.getMeetingChannelEntry(meetingID) + if appErr != nil { + return appErr + } + if existing != nil && existing.IsSubscription { + return nil + } + + entry := meetingChannelEntry{ + ChannelID: channelID, + IsSubscription: false, + } + data, err := json.Marshal(entry) + if err != nil { + return err + } + if appErr := p.API.KVSetWithExpiry(key, data, adHocMeetingChannelTTL); appErr != nil { + return appErr + } + return nil +} + +func (p *Plugin) getMeetingChannelEntry(meetingID int) (*meetingChannelEntry, *model.AppError) { + key := meetingChannelKVKey(meetingID) + raw, appErr := p.API.KVGet(key) + if appErr != nil { + return nil, appErr + } + if raw == nil { + return nil, nil + } + + var entry meetingChannelEntry + if err := json.Unmarshal(raw, &entry); err != nil { + p.API.LogWarn("failed to unmarshal meeting channel entry", + "key", key, + "error", err.Error(), + ) + return nil, model.NewAppError("getMeetingChannelEntry", "zoom.store.unmarshal_meeting_channel", nil, err.Error(), http.StatusInternalServerError) + } + if entry.ChannelID == "" { + return nil, nil + } + return &entry, nil +} + +func (p *Plugin) fetchChannelForMeeting(meetingID int) (string, *model.AppError) { + entry, appErr := p.getMeetingChannelEntry(meetingID) + if appErr != nil { + return "", appErr + } + if entry == nil { + return "", nil + } + return entry.ChannelID, nil +} + +func (p *Plugin) deleteChannelForMeeting(meetingID int) error { + key := meetingChannelKVKey(meetingID) return p.client.KV.Delete(key) } +const subscriptionIndexKey = "subscription_index_" + +type subscriptionIndex struct { + MeetingIDs []int `json:"meeting_ids"` +} + +func subscriptionIndexKVKey(userID string) string { + return subscriptionIndexKey + userID +} + +const subscriptionIndexMaxRetries = 5 + +func (p *Plugin) updateSubscriptionIndex(userID string, mutate func(*subscriptionIndex) *subscriptionIndex) error { + key := subscriptionIndexKVKey(userID) + + for i := 0; i < subscriptionIndexMaxRetries; i++ { + oldRaw, appErr := p.API.KVGet(key) + if appErr != nil { + return appErr + } + + var idx subscriptionIndex + if oldRaw != nil { + if err := json.Unmarshal(oldRaw, &idx); err != nil { + p.API.LogWarn("updateSubscriptionIndex: corrupted index data", + "user_id", userID, + "error", err.Error(), + ) + return errors.Wrap(err, "corrupted subscription index") + } + } + + updated := mutate(&idx) + if updated == nil { + return nil + } + + newRaw, err := json.Marshal(updated) + if err != nil { + return err + } + + ok, appErr := p.API.KVSetWithOptions(key, newRaw, model.PluginKVSetOptions{ + Atomic: true, + OldValue: oldRaw, + }) + if appErr != nil { + return appErr + } + if ok { + return nil + } + } + + return errors.New("updateSubscriptionIndex: too many concurrent updates") +} + +func (p *Plugin) addToSubscriptionIndex(userID string, meetingID int) error { + return p.updateSubscriptionIndex(userID, func(idx *subscriptionIndex) *subscriptionIndex { + for _, id := range idx.MeetingIDs { + if id == meetingID { + return nil + } + } + idx.MeetingIDs = append(idx.MeetingIDs, meetingID) + return idx + }) +} + +func (p *Plugin) removeFromSubscriptionIndex(userID string, meetingID int) error { + return p.updateSubscriptionIndex(userID, func(idx *subscriptionIndex) *subscriptionIndex { + filtered := make([]int, 0, len(idx.MeetingIDs)) + for _, id := range idx.MeetingIDs { + if id != meetingID { + filtered = append(filtered, id) + } + } + idx.MeetingIDs = filtered + return idx + }) +} + +func (p *Plugin) listAllMeetingSubscriptions(userID string) (map[string]string, error) { + raw, appErr := p.API.KVGet(subscriptionIndexKVKey(userID)) + if appErr != nil { + return nil, appErr + } + var idx subscriptionIndex + if raw != nil { + if err := json.Unmarshal(raw, &idx); err != nil { + p.API.LogWarn("failed to unmarshal subscription index", + "user_id", userID, + "error", err.Error(), + ) + return nil, errors.Wrap(err, "corrupted subscription index") + } + } + + subscriptions := make(map[string]string) + for _, meetingID := range idx.MeetingIDs { + entry, appErr := p.getMeetingChannelEntry(meetingID) + if appErr != nil || entry == nil || !entry.IsSubscription { + continue + } + if entry.CreatedBy != userID { + continue + } + subscriptions[fmt.Sprintf("%d", meetingID)] = entry.ChannelID + } + + return subscriptions, nil +} + // getOAuthUserStateKey generates and returns the key for storing the OAuth user state in the KV store. func getOAuthUserStateKey(userID string) string { return fmt.Sprintf("%s_%s", zoomStateKeyPrefix, userID) diff --git a/server/telemetry.go b/server/telemetry.go index a21056a9..100c124f 100644 --- a/server/telemetry.go +++ b/server/telemetry.go @@ -7,7 +7,8 @@ const ( telemetryOauthModeOauth = "Oauth" telemetryOauthModeOauthAccountLevel = "Oauth Account Level" - telemetryStartSourceCommand = "command" + telemetryStartSourceCommand = "command" + telemetryStartSourceSubscribeWebhook = "subscribe-webhook" ) func (p *Plugin) TrackEvent(event string, properties map[string]interface{}) { diff --git a/server/webhook.go b/server/webhook.go index 2d8b29bb..e26a6c63 100644 --- a/server/webhook.go +++ b/server/webhook.go @@ -13,6 +13,7 @@ import ( "io" "math" "net/http" + "net/url" "strconv" "strings" "time" @@ -23,7 +24,9 @@ import ( "github.com/mattermost/mattermost-plugin-zoom/server/zoom" ) +const bearerString = "Bearer " const maxWebhookBodySize = 1 << 20 // 1MB +const maxDownloadSize = 10 << 20 // 10MB func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) { if !p.verifyMattermostWebhookSecret(r) { @@ -68,15 +71,102 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) { } switch webhook.Event { + case zoom.EventTypeMeetingStarted: + p.handleMeetingStarted(w, r, b) case zoom.EventTypeMeetingEnded: p.handleMeetingEnded(w, r, b) case zoom.EventTypeValidateWebhook: p.handleValidateZoomWebhook(w, r, b) + case zoom.EventTypeRecordingCompleted: + p.handleRecordingCompleted(w, r, b) + case zoom.EventTypeTranscriptCompleted: + p.handleTranscriptCompleted(w, r, b) default: w.WriteHeader(http.StatusOK) } } +func (p *Plugin) handleMeetingStarted(w http.ResponseWriter, _ *http.Request, body []byte) { + var webhook zoom.MeetingWebhook + if err := json.Unmarshal(body, &webhook); err != nil { + p.API.LogError("Error unmarshaling meeting webhook", "err", err.Error()) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + meetingID, err := strconv.Atoi(webhook.Payload.Object.ID) + if err != nil { + p.API.LogError("Failed to get meeting ID", "err", err.Error()) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + entry, appErr := p.getMeetingChannelEntry(meetingID) + if appErr != nil { + p.API.LogWarn("handleMeetingStarted: failed to get meeting channel entry", + "meeting_id", meetingID, + "error", appErr.Error(), + ) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + if entry == nil || entry.ChannelID == "" { + w.WriteHeader(http.StatusOK) + return + } + + channelID := entry.ChannelID + + if entry.IsSubscription && entry.CreatedBy != "" { + if !p.API.HasPermissionToChannel(entry.CreatedBy, channelID, model.PermissionCreatePost) { + p.API.LogWarn("handleMeetingStarted: subscription creator lost channel access, skipping post", + "meeting_id", meetingID, + "channel_id", channelID, + "created_by", entry.CreatedBy, + ) + w.WriteHeader(http.StatusOK) + return + } + } + + // For ad-hoc meetings (started via /zoom start), a post already exists. + // Don't create a duplicate — just update the stored UUID mapping so that + // meeting.ended can find the post later. + // Subscription meetings should always create a new post. + if !entry.IsSubscription { + if existingPostID, err := p.findMeetingPostByMeetingID(meetingID); err == nil { + if webhook.Payload.Object.UUID == "" { + p.API.LogWarn("handleMeetingStarted: skipping UUID mapping — webhook UUID is empty", + "meeting_id", meetingID, + "post_id", existingPostID, + ) + } else if appErr := p.storeMeetingPostID(webhook.Payload.Object.UUID, existingPostID); appErr != nil { + p.API.LogWarn("failed to store UUID mapping for existing post", + "error", appErr.Error(), + ) + } + w.WriteHeader(http.StatusOK) + return + } + } + + botUser, appErr := p.API.GetUser(p.botUserID) + if appErr != nil { + p.API.LogError("Failed to get bot user", "err", appErr.Error()) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + if postMeetingErr := p.postMeeting(botUser, meetingID, webhook.Payload.Object.UUID, channelID, "", webhook.Payload.Object.Topic); postMeetingErr != nil { + p.API.LogError("Failed to post the zoom message in the channel", "err", postMeetingErr.Error()) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + p.trackMeetingStart(p.botUserID, telemetryStartSourceSubscribeWebhook) + p.trackMeetingType(p.botUserID, false) +} + func (p *Plugin) handleMeetingEnded(w http.ResponseWriter, _ *http.Request, body []byte) { var webhook zoom.MeetingWebhook if err := json.Unmarshal(body, &webhook); err != nil { @@ -85,21 +175,46 @@ func (p *Plugin) handleMeetingEnded(w http.ResponseWriter, _ *http.Request, body return } - meetingPostID := webhook.Payload.Object.ID - postID, err := p.fetchMeetingPostID(meetingPostID) + webhookMeetingID := webhook.Payload.Object.ID + webhookUUID := webhook.Payload.Object.UUID + + postID, err := p.fetchMeetingPostID(webhookUUID) if err != nil { - return + // The UUID Zoom sends at meeting.ended can differ from the one at creation + // (e.g. PMI meetings, or recurring meetings get a new UUID per occurrence). + // Fall back to finding the post via the meeting_channel mapping and recent posts. + meetingIDInt, atoiErr := strconv.Atoi(webhookMeetingID) + if atoiErr != nil { + http.Error(w, "meeting post not found", http.StatusNotFound) + return + } + + postID, err = p.findMeetingPostByMeetingID(meetingIDInt) + if err != nil { + p.API.LogWarn("could not find meeting post", + "meeting_id", meetingIDInt, + "error", err.Error(), + ) + http.Error(w, "meeting post not found", http.StatusNotFound) + return + } } post, err := p.client.Post.GetPost(postID) if err != nil { - p.client.Log.Warn("Could not get meeting post by id", "err", err.Error()) + p.client.Log.Warn("Could not get meeting post by id", "post_id", postID, "err", err.Error()) http.Error(w, err.Error(), http.StatusInternalServerError) return } + if post.Props["meeting_status"] == zoom.WebhookStatusEnded { + w.WriteHeader(http.StatusOK) + return + } + start := time.Unix(0, post.CreateAt*int64(time.Millisecond)) - length := int(math.Ceil(float64((model.GetMillis()-post.CreateAt)/1000) / 60)) + end := model.GetMillis() + length := int(math.Ceil(float64((end-post.CreateAt)/1000) / 60)) startText := start.Format("Mon Jan 2 15:04:05 -0700 MST 2006") topic, ok := post.Props["meeting_topic"].(string) if !ok { @@ -124,6 +239,7 @@ func (p *Plugin) handleMeetingEnded(w http.ResponseWriter, _ *http.Request, body post.Message = "The meeting has ended." post.Props["meeting_status"] = zoom.WebhookStatusEnded + post.Props["meeting_end_time"] = end post.Props["attachments"] = []*model.SlackAttachment{&slackAttachment} if err = p.client.Post.UpdatePost(post); err != nil { @@ -132,14 +248,301 @@ func (p *Plugin) handleMeetingEnded(w http.ResponseWriter, _ *http.Request, body return } - if err = p.deleteMeetingPostID(meetingPostID); err != nil { - p.client.Log.Warn("failed to delete db entry", "err", err.Error()) + // NOTE: We intentionally do NOT delete the meeting_channel mapping here. + // Recording and transcript webhooks arrive after meeting.ended and need + // the mapping to locate the post. The entry is small and gets overwritten + // if the same meeting ID is reused. + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(post); err != nil { + p.API.LogWarn("failed to write response", "error", err.Error()) + } +} + +func (p *Plugin) findMeetingPostByMeetingID(meetingID int) (string, error) { + return p.findMeetingPostByMeetingIDWithFilter(meetingID, true) +} + +// findMeetingPostByMeetingIDWithFilter searches recent posts in the channel +// associated with meetingID for a custom_zoom post matching that ID. +// When activeOnly is true, posts already marked as ENDED are skipped. +func (p *Plugin) findMeetingPostByMeetingIDWithFilter(meetingID int, activeOnly bool) (string, error) { + channelID, appErr := p.fetchChannelForMeeting(meetingID) + if appErr != nil || channelID == "" { + return "", errors.Errorf("no channel found for meeting %d", meetingID) + } + + since := model.GetMillis() - meetingPostIDTTL*1000 + postList, appErr := p.API.GetPostsSince(channelID, since) + if appErr != nil { + return "", errors.Wrap(appErr, "could not get recent posts for channel") + } + + // Prefer the most recently created matching post. + var bestPostID string + var bestCreateAt int64 + for _, post := range postList.Posts { + if post.Type != "custom_zoom" { + continue + } + propID, ok := post.Props["meeting_id"].(float64) + if !ok || int(propID) != meetingID { + continue + } + if activeOnly && post.Props["meeting_status"] == zoom.WebhookStatusEnded { + continue + } + if post.CreateAt > bestCreateAt { + bestPostID = post.Id + bestCreateAt = post.CreateAt + } + } + + if bestPostID != "" { + return bestPostID, nil + } + + return "", errors.Errorf("no meeting post found for meeting %d in channel %s (active_only=%v)", meetingID, channelID, activeOnly) +} + +// resolveRecordingMeetingPost finds the meeting post by UUID first, falling +// back to a meeting-ID-based search when the UUID doesn't match (PMI / +// recurring meetings get a new UUID per occurrence). +func (p *Plugin) resolveRecordingMeetingPost(webhookUUID string, meetingID int) (*model.Post, error) { + postID, err := p.fetchMeetingPostID(webhookUUID) + if err != nil { + // Recording/transcript webhooks arrive after meeting.ended, so the post + // is already marked ENDED. Use activeOnly=false to include ended posts. + postID, err = p.findMeetingPostByMeetingIDWithFilter(meetingID, false) + if err != nil { + return nil, errors.Wrapf(err, "could not find meeting post for uuid=%s meeting_id=%d", webhookUUID, meetingID) + } + } + + post, getErr := p.client.Post.GetPost(postID) + if getErr != nil { + return nil, errors.Wrap(getErr, "could not get meeting post by id") + } + + return post, nil +} + +func (p *Plugin) isZoomDownloadURL(rawURL string) bool { + parsed, err := url.Parse(rawURL) + if err != nil || parsed.Host == "" { + return false + } + if !strings.EqualFold(parsed.Scheme, "https") { + return false + } + host := strings.ToLower(parsed.Hostname()) + + for _, trusted := range []string{p.getZoomURL(), p.getZoomAPIURL()} { + trustedURL, err := url.Parse(trusted) + if err != nil || trustedURL.Host == "" { + continue + } + if !strings.EqualFold(trustedURL.Scheme, "https") { + continue + } + trustedHost := strings.ToLower(trustedURL.Hostname()) + if host == trustedHost || strings.HasSuffix(host, "."+trustedHost) { + return true + } + } + + return false +} + +// downloadZoomFile fetches a file from Zoom using the given download token, +// retrying up to maxRetries times on failure, then uploads it to the channel. +func (p *Plugin) downloadZoomFile(downloadURL, downloadToken, channelID, filename string, maxRetries int) (*model.FileInfo, error) { + if !p.isZoomDownloadURL(downloadURL) { + return nil, errors.Errorf("refusing to download from untrusted URL: %s", downloadURL) + } + + request, err := http.NewRequest(http.MethodGet, downloadURL, nil) // #nosec G107 -- URL is validated by isZoomDownloadURL above + if err != nil { + return nil, err + } + request.Header.Set("Authorization", bearerString+downloadToken) + + var response *http.Response + for attempt := 0; attempt <= maxRetries; attempt++ { + if attempt > 0 { + time.Sleep(time.Duration(1< maxDownloadSize { + return nil, errors.Errorf("download exceeds maximum size of %d bytes", maxDownloadSize) + } + + fileInfo, appErr := p.API.UploadFile(data, channelID, filename) + if appErr != nil { + return nil, appErr + } + + return fileInfo, nil +} + +func (p *Plugin) handleTranscript(recording zoom.RecordingFile, postID, channelID, downloadToken string) error { + fileInfo, err := p.downloadZoomFile(recording.DownloadURL, downloadToken, channelID, "transcription.txt", 5) + if err != nil { + p.API.LogWarn("Unable to download transcription", "err", err.Error()) + return err + } + + newPost := &model.Post{ + UserId: p.botUserID, + ChannelId: channelID, + RootId: postID, + Message: "Here's the zoom meeting transcription", + FileIds: []string{fileInfo.Id}, + Type: "custom_zoom_transcript", + } + newPost.AddProp("captions", []any{map[string]any{"file_id": fileInfo.Id}}) + + if _, appErr := p.API.CreatePost(newPost); appErr != nil { + p.API.LogWarn("Could not create transcription post", "err", appErr.Error()) + return appErr + } + + return nil +} + +func (p *Plugin) handleTranscriptCompleted(w http.ResponseWriter, _ *http.Request, body []byte) { + var webhook zoom.RecordingWebhook + if err := json.Unmarshal(body, &webhook); err != nil { + p.API.LogError("Error unmarshaling meeting webhook", "err", err.Error()) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + post, err := p.resolveRecordingMeetingPost(webhook.Payload.Object.UUID, webhook.Payload.Object.ID) + if err != nil { + p.API.LogWarn("Could not resolve meeting post for transcript", "error", err.Error()) + http.Error(w, "meeting post not found", http.StatusNotFound) + return + } + + lastTranscriptionIdx := -1 + for idx, recording := range webhook.Payload.Object.RecordingFiles { + if recording.RecordingType == zoom.RecordingTypeAudioTranscript { + lastTranscriptionIdx = idx + } + } + + if lastTranscriptionIdx != -1 { + err := p.handleTranscript(webhook.Payload.Object.RecordingFiles[lastTranscriptionIdx], post.Id, post.ChannelId, webhook.DownloadToken) + if err != nil { + http.Error(w, "failed to process transcript", http.StatusInternalServerError) + return + } + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(post); err != nil { + p.API.LogWarn("failed to write response", "error", err.Error()) + } +} + +func (p *Plugin) handleRecordingCompleted(w http.ResponseWriter, _ *http.Request, body []byte) { + var webhook zoom.RecordingWebhook + if err := json.Unmarshal(body, &webhook); err != nil { + p.API.LogError("handleRecordingCompleted: failed to unmarshal", "err", err.Error()) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + post, err := p.resolveRecordingMeetingPost(webhook.Payload.Object.UUID, webhook.Payload.Object.ID) + if err != nil { + p.API.LogWarn("handleRecordingCompleted: could not resolve meeting post", "error", err.Error()) + http.Error(w, "meeting post not found", http.StatusNotFound) return } + recordings := make(map[time.Time][]zoom.RecordingFile) + + for _, recording := range webhook.Payload.Object.RecordingFiles { + switch { + case recording.RecordingType == zoom.RecordingTypeChat: + recordings[recording.RecordingStart] = append(recordings[recording.RecordingStart], recording) + case strings.EqualFold(recording.FileType, zoom.RecordingFileTypeMP4): + recordings[recording.RecordingStart] = append(recordings[recording.RecordingStart], recording) + } + } + + for _, recordingGroup := range recordings { + newPost := &model.Post{ + UserId: p.botUserID, + ChannelId: post.ChannelId, + RootId: post.Id, + Message: "", + FileIds: []string{}, + } + for _, recording := range recordingGroup { + if recording.RecordingType == zoom.RecordingTypeChat { + fileInfo, chatErr := p.downloadZoomFile(recording.DownloadURL, webhook.DownloadToken, post.ChannelId, "Chat-history.txt", 5) + if chatErr != nil { + p.API.LogWarn("handleRecordingCompleted: failed to download/upload chat", "error", chatErr.Error()) + http.Error(w, "failed to process recording webhook", http.StatusInternalServerError) + return + } + + newPost.FileIds = append(newPost.FileIds, fileInfo.Id) + newPost.AddProp("captions", []any{map[string]any{"file_id": fileInfo.Id}}) + newPost.Type = "custom_zoom_chat" + } else if strings.EqualFold(recording.FileType, zoom.RecordingFileTypeMP4) && recording.PlayURL != "" { + if !p.isZoomDownloadURL(recording.PlayURL) { + p.API.LogWarn("handleRecordingCompleted: refusing to post untrusted play URL", "url", recording.PlayURL) + continue + } + msg := "Here's the zoom meeting recording:\n**Link:** [Meeting Recording](" + recording.PlayURL + ")" + if webhook.Payload.Object.Password != "" && p.getConfiguration().EnablePostingRecordingPassword { + msg += "\n**Password:** `" + webhook.Payload.Object.Password + "`" + } + if newPost.Message != "" { + newPost.Message += "\n\n" + } + newPost.Message += msg + } + } + + if newPost.Message != "" || len(newPost.FileIds) > 0 { + if _, appErr := p.API.CreatePost(newPost); appErr != nil { + p.API.LogWarn("handleRecordingCompleted: could not create post", "err", appErr) + http.Error(w, "failed to create recording post", http.StatusInternalServerError) + return + } + } + } + w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(post); err != nil { - p.client.Log.Warn("failed to write response", "error", err.Error()) + p.API.LogWarn("failed to write response", "error", err.Error()) } } diff --git a/server/webhook_test.go b/server/webhook_test.go index 1ce51f85..f440b6ce 100644 --- a/server/webhook_test.go +++ b/server/webhook_test.go @@ -5,23 +5,41 @@ package main import ( "bytes" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" "encoding/json" "fmt" "io" + "net/http" "net/http/httptest" "testing" "time" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/plugin" "github.com/mattermost/mattermost/server/public/plugin/plugintest" "github.com/mattermost/mattermost/server/public/pluginapi" + "github.com/mattermost/mattermost/server/public/pluginapi/experimental/telemetry" "github.com/mattermost/mattermost-plugin-zoom/server/zoom" ) +func allowFlexibleLogging(api *plugintest.API) { + for _, method := range []string{"LogDebug", "LogWarn", "LogError"} { + api.On(method, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Maybe().Return() + api.On(method, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Maybe().Return() + api.On(method, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Maybe().Return() + api.On(method, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Maybe().Return() + api.On(method, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Maybe().Return() + api.On(method, mock.Anything, mock.Anything, mock.Anything).Maybe().Return() + api.On(method, mock.Anything).Maybe().Return() + } +} + var testConfig = &configuration{ OAuthClientID: "clientid", OAuthClientSecret: "clientsecret", @@ -36,6 +54,7 @@ func TestWebhookValidate(t *testing.T) { p.setConfiguration(testConfig) api.On("GetLicense").Return(nil) + allowFlexibleLogging(api) p.SetAPI(api) requestBody := `{"payload":{"plainToken":"Kn5a3Wv7SP6YP5b4BWfZpg"},"event":"endpoint.url_validation"}` @@ -59,6 +78,156 @@ func TestWebhookValidate(t *testing.T) { require.Equal(t, "2a41c3138d2187a756c51428f78d192e9b88dcf44dd62d1b081ace4ec2241e0a", out.EncryptedToken) } +func TestHandleMeetingStarted(t *testing.T) { + p := Plugin{} + p.setConfiguration(testConfig) + + t.Run("successful meeting start", func(t *testing.T) { + api := &plugintest.API{} + api.On("GetLicense").Return(nil) + meetingEntry, _ := json.Marshal(meetingChannelEntry{ChannelID: "channel-id"}) + api.On("KVGet", "meeting_channel_123").Return(meetingEntry, nil) + api.On("GetUser", "test-bot-id").Return(&model.User{Id: "test-bot-id"}, nil) + api.On("KVGet", "zoomtoken_test-bot-id").Return(nil, &model.AppError{}) + api.On("LogWarn", "could not get the active Zoom client", "error", "could not fetch Zoom OAuth info: must connect user account to Zoom first").Return() + api.On("HasPermissionToChannel", "test-bot-id", "channel-id", mock.AnythingOfType("*model.Permission")).Return(true) + api.On("KVSetWithExpiry", "post_meeting_abc", []byte{}, int64(86400)).Return(nil) + api.On("KVSetWithExpiry", "meeting_channel_123", mock.AnythingOfType("[]uint8"), int64(adHocMeetingChannelTTL)).Return(nil) + api.On("PublishWebSocketEvent", "meeting_started", map[string]interface{}{"meeting_url": "https://zoom.us/j/123"}, mock.AnythingOfType("*model.WebsocketBroadcast")).Return() + api.On("CreatePost", mock.AnythingOfType("*model.Post")).Return(&model.Post{}, nil) + api.On("GetPostsSince", "channel-id", mock.AnythingOfType("int64")).Return(&model.PostList{}, nil) + allowFlexibleLogging(api) + p.SetAPI(api) + p.client = pluginapi.NewClient(api, nil) + p.botUserID = "test-bot-id" + p.tracker = telemetry.NewTracker(nil, "", "", "", "", "", telemetry.NewTrackerConfig(nil), nil) + + requestBody := `{"payload":{"object": {"id": "123", "uuid": "abc", "topic": "test meeting"}},"event":"meeting.started"}` + w := httptest.NewRecorder() + reqBody := io.NopCloser(bytes.NewBufferString(requestBody)) + request := httptest.NewRequest("POST", "/webhook?secret=webhooksecret", reqBody) + request.Header.Add("Content-Type", "application/json") + + ts := fmt.Sprintf("%d", time.Now().Unix()) + h := hmac.New(sha256.New, []byte(testConfig.ZoomWebhookSecret)) + _, _ = h.Write([]byte("v0:" + ts + ":" + requestBody)) + signature := "v0=" + hex.EncodeToString(h.Sum(nil)) + + request.Header.Add("x-zm-signature", signature) + request.Header.Add("x-zm-request-timestamp", ts) + + p.ServeHTTP(&plugin.Context{}, w, request) + require.Equal(t, http.StatusOK, w.Result().StatusCode) + }) + + t.Run("invalid meeting ID", func(t *testing.T) { + api := &plugintest.API{} + api.On("GetLicense").Return(nil) + api.On("LogError", "Failed to get meeting ID", "err", "strconv.Atoi: parsing \"invalid\": invalid syntax").Return() + allowFlexibleLogging(api) + p.SetAPI(api) + + requestBody := `{"payload":{"object": {"id": "invalid", "uuid": "123-abc"}},"event":"meeting.started"}` + w := httptest.NewRecorder() + reqBody := io.NopCloser(bytes.NewBufferString(requestBody)) + request := httptest.NewRequest("POST", "/webhook?secret=webhooksecret", reqBody) + request.Header.Add("Content-Type", "application/json") + + ts := fmt.Sprintf("%d", time.Now().Unix()) + h := hmac.New(sha256.New, []byte(testConfig.ZoomWebhookSecret)) + _, _ = h.Write([]byte("v0:" + ts + ":" + requestBody)) + signature := "v0=" + hex.EncodeToString(h.Sum(nil)) + + request.Header.Add("x-zm-signature", signature) + request.Header.Add("x-zm-request-timestamp", ts) + + p.ServeHTTP(&plugin.Context{}, w, request) + require.Equal(t, http.StatusBadRequest, w.Result().StatusCode) + }) + + t.Run("channel lookup error returns 500", func(t *testing.T) { + api := &plugintest.API{} + api.On("GetLicense").Return(nil) + api.On("KVGet", "meeting_channel_123").Return(nil, &model.AppError{Message: "kv error"}) + allowFlexibleLogging(api) + p.SetAPI(api) + + requestBody := `{"payload":{"object": {"id": "123", "uuid": "123-abc"}},"event":"meeting.started"}` + w := httptest.NewRecorder() + reqBody := io.NopCloser(bytes.NewBufferString(requestBody)) + request := httptest.NewRequest("POST", "/webhook?secret=webhooksecret", reqBody) + request.Header.Add("Content-Type", "application/json") + + ts := fmt.Sprintf("%d", time.Now().Unix()) + h := hmac.New(sha256.New, []byte(testConfig.ZoomWebhookSecret)) + _, _ = h.Write([]byte("v0:" + ts + ":" + requestBody)) + signature := "v0=" + hex.EncodeToString(h.Sum(nil)) + + request.Header.Add("x-zm-signature", signature) + request.Header.Add("x-zm-request-timestamp", ts) + + p.ServeHTTP(&plugin.Context{}, w, request) + require.Equal(t, http.StatusInternalServerError, w.Result().StatusCode) + }) + + t.Run("subscription creator lost channel access returns 200 without posting", func(t *testing.T) { + api := &plugintest.API{} + api.On("GetLicense").Return(nil) + meetingEntry, _ := json.Marshal(meetingChannelEntry{ + ChannelID: "channel-id", + IsSubscription: true, + CreatedBy: "creator-user-id", + }) + api.On("KVGet", "meeting_channel_123").Return(meetingEntry, nil) + api.On("HasPermissionToChannel", "creator-user-id", "channel-id", model.PermissionCreatePost).Return(false) + allowFlexibleLogging(api) + p.SetAPI(api) + + requestBody := `{"payload":{"object": {"id": "123", "uuid": "abc"}},"event":"meeting.started"}` + w := httptest.NewRecorder() + reqBody := io.NopCloser(bytes.NewBufferString(requestBody)) + request := httptest.NewRequest("POST", "/webhook?secret=webhooksecret", reqBody) + request.Header.Add("Content-Type", "application/json") + + ts := fmt.Sprintf("%d", time.Now().Unix()) + h := hmac.New(sha256.New, []byte(testConfig.ZoomWebhookSecret)) + _, _ = h.Write([]byte("v0:" + ts + ":" + requestBody)) + signature := "v0=" + hex.EncodeToString(h.Sum(nil)) + + request.Header.Add("x-zm-signature", signature) + request.Header.Add("x-zm-request-timestamp", ts) + + p.ServeHTTP(&plugin.Context{}, w, request) + require.Equal(t, http.StatusOK, w.Result().StatusCode) + api.AssertNotCalled(t, "CreatePost", mock.Anything) + }) + + t.Run("no channel entry returns 200", func(t *testing.T) { + api := &plugintest.API{} + api.On("GetLicense").Return(nil) + api.On("KVGet", "meeting_channel_123").Return(nil, (*model.AppError)(nil)) + allowFlexibleLogging(api) + p.SetAPI(api) + + requestBody := `{"payload":{"object": {"id": "123", "uuid": "123-abc"}},"event":"meeting.started"}` + w := httptest.NewRecorder() + reqBody := io.NopCloser(bytes.NewBufferString(requestBody)) + request := httptest.NewRequest("POST", "/webhook?secret=webhooksecret", reqBody) + request.Header.Add("Content-Type", "application/json") + + ts := fmt.Sprintf("%d", time.Now().Unix()) + h := hmac.New(sha256.New, []byte(testConfig.ZoomWebhookSecret)) + _, _ = h.Write([]byte("v0:" + ts + ":" + requestBody)) + signature := "v0=" + hex.EncodeToString(h.Sum(nil)) + + request.Header.Add("x-zm-signature", signature) + request.Header.Add("x-zm-request-timestamp", ts) + + p.ServeHTTP(&plugin.Context{}, w, request) + require.Equal(t, http.StatusOK, w.Result().StatusCode) + }) +} + func TestWebhookVerifySignature(t *testing.T) { t.Run("recent timestamp with valid signature", func(t *testing.T) { api := &plugintest.API{} @@ -66,12 +235,13 @@ func TestWebhookVerifySignature(t *testing.T) { p.setConfiguration(testConfig) api.On("GetLicense").Return(nil) - api.On("KVGet", "post_meeting_123").Return(nil, &model.AppError{StatusCode: 200}) - api.On("LogDebug", "Could not get meeting post from KVStore", "error", "") + api.On("KVGet", "post_meeting_123-abc").Return(nil, &model.AppError{StatusCode: 200}) + api.On("KVGet", "meeting_channel_123").Return(nil, (*model.AppError)(nil)) + allowFlexibleLogging(api) p.SetAPI(api) p.client = pluginapi.NewClient(p.API, p.Driver) - requestBody := `{"payload":{"object": {"id": "123"}},"event":"meeting.ended"}` + requestBody := `{"payload":{"object": {"id": "123", "uuid": "123-abc"}},"event":"meeting.ended"}` ts := fmt.Sprintf("%d", time.Now().Unix()) msg := fmt.Sprintf("v0:%s:%s", ts, requestBody) @@ -89,7 +259,7 @@ func TestWebhookVerifySignature(t *testing.T) { body, _ := io.ReadAll(w.Result().Body) t.Log(string(body)) - require.Equal(t, 200, w.Result().StatusCode) + require.Equal(t, http.StatusNotFound, w.Result().StatusCode) }) t.Run("old timestamp is rejected", func(t *testing.T) { @@ -99,6 +269,7 @@ func TestWebhookVerifySignature(t *testing.T) { api.On("GetLicense").Return(nil) api.On("LogWarn", "Could not verify webhook signature: webhook timestamp is too old") + allowFlexibleLogging(api) p.SetAPI(api) requestBody := `{"payload":{"object": {"id": "123"}},"event":"meeting.ended"}` @@ -127,6 +298,7 @@ func TestWebhookVerifySignature(t *testing.T) { api.On("GetLicense").Return(nil) api.On("LogWarn", "Could not verify webhook signature: webhook timestamp is too far in the future") + allowFlexibleLogging(api) p.SetAPI(api) requestBody := `{"payload":{"object": {"id": "123"}},"event":"meeting.ended"}` @@ -164,11 +336,16 @@ func TestWebhookEmptyZoomWebhookSecret(t *testing.T) { api.On("GetLicense").Return(nil) api.On("KVGet", "post_meeting_123").Return(nil, &model.AppError{StatusCode: 200}) - api.On("LogDebug", "Could not get meeting post from KVStore", "error", "") + api.On("KVGet", "meeting_channel_123").Return(nil, (*model.AppError)(nil)) + allowFlexibleLogging(api) p.SetAPI(api) p.client = pluginapi.NewClient(p.API, p.Driver) - requestBody := `{"payload":{"object": {"id": "123"}},"event":"meeting.ended"}` + requestBody := `{"payload":{"object": {"id": "123", "uuid": "123"}},"event":"meeting.ended"}` + + ts := "1660149894817" + h := hmac.New(sha256.New, []byte(testConfig.ZoomWebhookSecret)) + _, _ = h.Write([]byte("v0:" + ts + ":" + requestBody)) w := httptest.NewRecorder() reqBody := io.NopCloser(bytes.NewBufferString(requestBody)) @@ -177,7 +354,7 @@ func TestWebhookEmptyZoomWebhookSecret(t *testing.T) { p.ServeHTTP(&plugin.Context{}, w, request) - require.Equal(t, 200, w.Result().StatusCode) + require.Equal(t, http.StatusNotFound, w.Result().StatusCode) } func TestWebhookVerifySignatureInvalid(t *testing.T) { @@ -187,6 +364,7 @@ func TestWebhookVerifySignatureInvalid(t *testing.T) { api.On("GetLicense").Return(nil) api.On("LogWarn", "Could not verify webhook signature: provided signature does not match") + allowFlexibleLogging(api) p.SetAPI(api) requestBody := `{"payload":{"object": {"id": "123"}},"event":"meeting.ended"}` @@ -231,3 +409,186 @@ func TestWebhookBodyTooLarge(t *testing.T) { require.Equal(t, 413, w.Result().StatusCode) } + +func TestWebhookHandleTranscriptCompleted(t *testing.T) { + api := &plugintest.API{} + p := Plugin{} + + httpServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + _, _ = w.Write([]byte(r.URL.Path)) + })) + defer httpServer.Close() + + cfg := *testConfig + cfg.ZoomURL = httpServer.URL + p.setConfiguration(&cfg) + + p.downloadClient = httpServer.Client() + + api.On("GetLicense").Return(nil) + api.On("GetPost", "post-id").Return(&model.Post{Id: "post-id", ChannelId: "channel-id"}, nil) + api.On("KVGet", "post_meeting_321").Return([]byte("post-id"), nil) + allowFlexibleLogging(api) + api.On("UploadFile", []byte("/test"), "channel-id", "transcription.txt").Return(&model.FileInfo{Id: "file-id"}, nil) + p.client = pluginapi.NewClient(api, nil) + api.On("CreatePost", &model.Post{ + ChannelId: "channel-id", + RootId: "post-id", + Message: "Here's the zoom meeting transcription", + Type: "custom_zoom_transcript", + Props: model.StringInterface{ + "captions": []any{map[string]any{"file_id": "file-id"}}, + }, + FileIds: []string{"file-id"}, + }).Return(&model.Post{ + ChannelId: "channel-id", + RootId: "post-id", + Message: "Here's the zoom meeting transcription", + Type: "custom_zoom_transcript", + Props: model.StringInterface{ + "captions": []any{map[string]any{"file_id": "file-id"}}, + }, + FileIds: []string{"file-id"}, + }, nil) + p.SetAPI(api) + + requestBodyBytes, _ := json.Marshal(map[string]any{ + "payload": map[string]any{ + "object": map[string]any{ + "id": 123, + "uuid": "321", + "recording_files": []map[string]any{ + { + "recording_type": "audio_transcript", + "download_url": httpServer.URL + "/test", + }, + }, + }, + }, + "event": "recording.transcript_completed", + "download_token": "test-token", + }) + requestBody := string(requestBodyBytes) + + ts := fmt.Sprintf("%d", time.Now().Unix()) + h := hmac.New(sha256.New, []byte(testConfig.ZoomWebhookSecret)) + _, _ = h.Write([]byte("v0:" + ts + ":" + requestBody)) + signature := fmt.Sprintf("v0=%s", hex.EncodeToString(h.Sum(nil))) + + w := httptest.NewRecorder() + reqBody := io.NopCloser(bytes.NewBufferString(requestBody)) + request := httptest.NewRequest("POST", "/webhook?secret=webhooksecret", reqBody) + request.Header.Add("Content-Type", "application/json") + request.Header.Add("x-zm-signature", signature) + request.Header.Add("x-zm-request-timestamp", ts) + + p.ServeHTTP(&plugin.Context{}, w, request) + require.Equal(t, http.StatusOK, w.Result().StatusCode) + body, _ := io.ReadAll(w.Result().Body) + t.Log(string(body)) + + api.AssertExpectations(t) +} + +func TestWebhookHandleRecordingCompleted(t *testing.T) { + api := &plugintest.API{} + p := Plugin{} + + httpServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + _, _ = w.Write([]byte(r.URL.Path)) + })) + defer httpServer.Close() + + cfg := *testConfig + cfg.ZoomURL = httpServer.URL + p.setConfiguration(&cfg) + + p.downloadClient = httpServer.Client() + + api.On("GetLicense").Return(nil) + api.On("GetPost", "post-id").Return(&model.Post{Id: "post-id", ChannelId: "channel-id"}, nil) + api.On("KVGet", "post_meeting_321").Return([]byte("post-id"), nil) + allowFlexibleLogging(api) + api.On("UploadFile", []byte("/chat_file"), "channel-id", "Chat-history.txt").Return(&model.FileInfo{Id: "file-id"}, nil) + p.client = pluginapi.NewClient(api, nil) + api.On("CreatePost", mock.AnythingOfType("*model.Post")).Return(&model.Post{}, nil) + p.SetAPI(api) + + now := time.Now() + requestBodyBytes, _ := json.Marshal(map[string]any{ + "payload": map[string]any{ + "object": map[string]any{ + "id": 123, + "uuid": "321", + "password": "test-password", + "recording_files": []map[string]any{ + { + "recording_start": now, + "recording_type": "chat_file", + "download_url": httpServer.URL + "/chat_file", + }, + { + "recording_start": now, + "recording_type": "shared_screen_with_speaker_view(CC)", + "file_type": "MP4", + "download_url": httpServer.URL + "/recording_file", + "play_url": httpServer.URL + "/recording_url", + }, + }, + }, + }, + "event": "recording.completed", + "download_token": "test-token", + }) + requestBody := string(requestBodyBytes) + + ts := fmt.Sprintf("%d", time.Now().Unix()) + h := hmac.New(sha256.New, []byte(testConfig.ZoomWebhookSecret)) + _, _ = h.Write([]byte("v0:" + ts + ":" + requestBody)) + signature := fmt.Sprintf("v0=%s", hex.EncodeToString(h.Sum(nil))) + + w := httptest.NewRecorder() + reqBody := io.NopCloser(bytes.NewBufferString(requestBody)) + request := httptest.NewRequest("POST", "/webhook?secret=webhooksecret", reqBody) + request.Header.Add("Content-Type", "application/json") + request.Header.Add("x-zm-signature", signature) + request.Header.Add("x-zm-request-timestamp", ts) + + p.ServeHTTP(&plugin.Context{}, w, request) + require.Equal(t, http.StatusOK, w.Result().StatusCode) + body, _ := io.ReadAll(w.Result().Body) + t.Log(string(body)) + + api.AssertExpectations(t) +} + +func TestIsZoomDownloadURL(t *testing.T) { + p := Plugin{} + p.setConfiguration(testConfig) + + tests := []struct { + name string + url string + want bool + }{ + {"exact zoom.us host", "https://zoom.us/rec/download/abc", true}, + {"org subdomain of zoom.us", "https://mattermost.zoom.us/rec/webhook_download/abc", true}, + {"deep subdomain of zoom.us", "https://a.b.zoom.us/rec/download/abc", true}, + {"exact api.zoom.us host", "https://api.zoom.us/v2/recordings/download", true}, + {"subdomain of api.zoom.us", "https://sub.api.zoom.us/v2/download", true}, + {"http scheme rejected", "http://zoom.us/rec/download/abc", false}, + {"unrelated host", "https://random.com/rec/download/abc", false}, + {"suffix trick (notzoom.us)", "https://notzoom.us/rec/download/abc", false}, + {"empty string", "", false}, + {"no host", "https:///path", false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := p.isZoomDownloadURL(tc.url) + require.Equal(t, tc.want, got, "isZoomDownloadURL(%q)", tc.url) + }) + } +} diff --git a/server/zoom/meeting.go b/server/zoom/meeting.go index fe262686..e29d3bc2 100644 --- a/server/zoom/meeting.go +++ b/server/zoom/meeting.go @@ -13,6 +13,8 @@ const ( MeetingTypeScheduled MeetingType = 2 // MeetingTypeRecurringWithNoFixedTime meeting MeetingTypeRecurringWithNoFixedTime MeetingType = 3 + // MeetingTypePersonal meeting + MeetingTypePersonal MeetingType = 4 // MeetingTypeRecurringWithFixedTime meeting MeetingTypeRecurringWithFixedTime MeetingType = 8 ) diff --git a/server/zoom/webhook.go b/server/zoom/webhook.go index 042572d6..cfdf9c84 100644 --- a/server/zoom/webhook.go +++ b/server/zoom/webhook.go @@ -15,9 +15,16 @@ const ( RecordingWebhookTypeComplete = "RECORDING_MEETING_COMPLETED" RecentlyCreated = "RECENTLY_CREATED" - EventTypeMeetingStarted EventType = "meeting.started" - EventTypeMeetingEnded EventType = "meeting.ended" - EventTypeValidateWebhook EventType = "endpoint.url_validation" + EventTypeMeetingStarted EventType = "meeting.started" + EventTypeMeetingEnded EventType = "meeting.ended" + EventTypeTranscriptCompleted EventType = "recording.transcript_completed" + EventTypeRecordingCompleted EventType = "recording.completed" + EventTypeValidateWebhook EventType = "endpoint.url_validation" + + RecordingTypeAudioTranscript = "audio_transcript" + RecordingTypeChat = "chat_file" + + RecordingFileTypeMP4 = "MP4" ) type MeetingWebhookObject struct { @@ -63,32 +70,45 @@ type Webhook struct { } type RecordingWebhook struct { - Type string `schema:"type"` - Content string `schema:"content"` + Type string `json:"event"` + DownloadToken string `json:"download_token"` + Payload RecordingWebhookPayload `json:"payload"` +} + +type RecordingWebhookPayload struct { + AccountID string `json:"account_id"` + Object RecordingWebhookObject `json:"object"` +} + +type RecordingFile struct { + ID string `json:"id"` + MeetingID string `json:"meeting_id"` + RecordingStart time.Time `json:"recording_start"` + RecordingEnd time.Time `json:"recording_end"` + FileType string `json:"file_type"` + FileSize int `json:"file_size"` + FilePath string `json:"file_path"` + Status string `json:"status"` + DownloadURL string `json:"download_url"` + PlayURL string `json:"play_url"` + RecordingType string `json:"recording_type"` } -type RecordingWebhookContent struct { - UUID string `json:"uuid"` - MeetingNumber int `json:"meeting_number"` - AccountID string `json:"account_id"` - HostID string `json:"host_id"` - Topic string `json:"topic"` - StartTime time.Time `json:"start_time"` - Timezone string `json:"timezone"` - HostEmail string `json:"host_email"` - Duration int `json:"duration"` - TotalSize int `json:"total_size"` - RecordingCount int `json:"recording_count"` - RecordingFiles []struct { - ID string `json:"id"` - MeetingID string `json:"meeting_id"` - RecordingStart time.Time `json:"recording_start"` - RecordingEnd time.Time `json:"recording_end"` - FileType string `json:"file_type"` - FileSize int `json:"file_size"` - FilePath string `json:"file_path"` - Status string `json:"status"` - } `json:"recording_files"` +type RecordingWebhookObject struct { + UUID string `json:"uuid"` + MeetingNumber int `json:"meeting_number"` + ID int `json:"id"` + AccountID string `json:"account_id"` + HostID string `json:"host_id"` + Topic string `json:"topic"` + StartTime time.Time `json:"start_time"` + Timezone string `json:"timezone"` + HostEmail string `json:"host_email"` + Duration int `json:"duration"` + TotalSize int `json:"total_size"` + RecordingCount int `json:"recording_count"` + Password string `json:"password"` + RecordingFiles []RecordingFile `json:"recording_files"` } type DeauthorizationEvent struct { diff --git a/webapp/.eslintrc.json b/webapp/.eslintrc.json index c279e091..37bc62a4 100644 --- a/webapp/.eslintrc.json +++ b/webapp/.eslintrc.json @@ -485,7 +485,7 @@ 2, "never" ], - "react/jsx-filename-extension": [1, { "extensions": [".js", ".jsx"] }], + "react/jsx-filename-extension": [1, { "extensions": [".js", ".jsx", ".tsx"] }], "react/jsx-first-prop-new-line": [ 2, "multiline" @@ -641,5 +641,30 @@ "onlyEquality": false } ] - } + }, + "overrides": [ + { + "files": ["**/*.ts", "**/*.tsx"], + "parser": "@typescript-eslint/parser", + "parserOptions": { + "project": "./tsconfig.json" + }, + "plugins": ["@typescript-eslint"], + "rules": { + "no-undef": "off", + "no-unused-vars": "off", + "@typescript-eslint/no-unused-vars": [2, { + "vars": "all", + "args": "after-used" + }], + "no-use-before-define": "off", + "no-shadow": "off", + "@typescript-eslint/no-shadow": [2, { + "hoist": "functions" + }], + "no-redeclare": "off", + "no-dupe-class-members": "off" + } + } + ] } diff --git a/webapp/package-lock.json b/webapp/package-lock.json index 57b442cb..b04f4edc 100644 --- a/webapp/package-lock.json +++ b/webapp/package-lock.json @@ -17,6 +17,7 @@ "react-intl": "6.8.0", "react-redux": "8.1.3", "redux": "4.2.1", + "styled-components": "6.1.10", "typescript": "5.7.3" }, "devDependencies": { @@ -2116,6 +2117,21 @@ "dev": true, "license": "MIT" }, + "node_modules/@emotion/is-prop-valid": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.2.2.tgz", + "integrity": "sha512-uNsoYd37AFmaCdXlg6EYD1KaPOaRWRByMCYzbKUX4+hhMfrxdVSelShywL4JVaAeM/eHUOSprYBQls+/neX3pw==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.8.1" + } + }, + "node_modules/@emotion/is-prop-valid/node_modules/@emotion/memoize": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.8.1.tgz", + "integrity": "sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA==", + "license": "MIT" + }, "node_modules/@emotion/memoize": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", @@ -3530,6 +3546,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/stylis": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@types/stylis/-/stylis-4.2.5.tgz", + "integrity": "sha512-1Xve+NMN7FWjY14vLoY5tL3BVEQ/n42YLwaqJIPYhotZ9uBHt87VceMwWQpzmdEt2TNXIorIFG+YeCUUW7RInw==", + "license": "MIT" + }, "node_modules/@types/tough-cookie": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", @@ -4798,6 +4820,15 @@ "node": ">=6" } }, + "node_modules/camelize": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz", + "integrity": "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001766", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz", @@ -5103,6 +5134,26 @@ "node": ">= 8" } }, + "node_modules/css-color-keywords": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz", + "integrity": "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==", + "license": "ISC", + "engines": { + "node": ">=4" + } + }, + "node_modules/css-to-react-native": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/css-to-react-native/-/css-to-react-native-3.2.0.tgz", + "integrity": "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==", + "license": "MIT", + "dependencies": { + "camelize": "^1.0.0", + "css-color-keywords": "^1.0.0", + "postcss-value-parser": "^4.0.2" + } + }, "node_modules/css.escape": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", @@ -9446,6 +9497,24 @@ "dev": true, "license": "MIT" }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -9843,7 +9912,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, "license": "ISC" }, "node_modules/picomatch": { @@ -9993,6 +10061,40 @@ "node": ">= 0.4" } }, + "node_modules/postcss": { + "version": "8.4.38", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz", + "integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.7", + "picocolors": "^1.0.0", + "source-map-js": "^1.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "license": "MIT" + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -10844,6 +10946,12 @@ "integrity": "sha512-xd/FKcdmfmMbyYCca3QTVEJtqUOGuajNzvAX6nt8dXILwjAIEkfHc4hI8/JMGApAmb7VeULO0Q30NTxnbH/15g==", "license": "MIT" }, + "node_modules/shallowequal": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", + "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -10977,6 +11085,15 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-support": { "version": "0.5.21", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", @@ -11228,6 +11345,58 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/styled-components": { + "version": "6.1.10", + "resolved": "https://registry.npmjs.org/styled-components/-/styled-components-6.1.10.tgz", + "integrity": "sha512-4K8IKcn7iOt76riGLjvBhRyNPTkUKTvmnwoRFBOtJLswVvzy2VsoE2KOrfl9FJLQUYbITLJY2wfIZ3tjbkA/Zw==", + "license": "MIT", + "dependencies": { + "@emotion/is-prop-valid": "1.2.2", + "@emotion/unitless": "0.8.1", + "@types/stylis": "4.2.5", + "css-to-react-native": "3.2.0", + "csstype": "3.1.3", + "postcss": "8.4.38", + "shallowequal": "1.1.0", + "stylis": "4.3.2", + "tslib": "2.6.2" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/styled-components" + }, + "peerDependencies": { + "react": ">= 16.8.0", + "react-dom": ">= 16.8.0" + } + }, + "node_modules/styled-components/node_modules/@emotion/unitless": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.8.1.tgz", + "integrity": "sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ==", + "license": "MIT" + }, + "node_modules/styled-components/node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "license": "MIT" + }, + "node_modules/styled-components/node_modules/stylis": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.2.tgz", + "integrity": "sha512-bhtUjWd/z6ltJiQwg0dUfxEJ+W+jdqQd8TbWLWyeIJHlnsqmGLRFFd8e5mA0AZi/zx90smXRlN66YMTcaSFifg==", + "license": "MIT" + }, + "node_modules/styled-components/node_modules/tslib": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", + "license": "0BSD" + }, "node_modules/stylis": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", diff --git a/webapp/package.json b/webapp/package.json index e2ce7fc1..793ecae4 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -27,6 +27,7 @@ "react-intl": "6.8.0", "react-redux": "8.1.3", "redux": "4.2.1", + "styled-components": "6.1.10", "typescript": "5.7.3" }, "devDependencies": { diff --git a/webapp/src/components/ai_icon.tsx b/webapp/src/components/ai_icon.tsx new file mode 100644 index 00000000..16620dae --- /dev/null +++ b/webapp/src/components/ai_icon.tsx @@ -0,0 +1,36 @@ +// Copyright (c) 2019-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import Svg from './svg'; + +const IconAI = () => ( + + + + + + +); + +export default IconAI; diff --git a/webapp/src/components/ai_summary_button.tsx b/webapp/src/components/ai_summary_button.tsx new file mode 100644 index 00000000..8537a93a --- /dev/null +++ b/webapp/src/components/ai_summary_button.tsx @@ -0,0 +1,97 @@ +// Copyright (c) 2019-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback} from 'react'; +import {FormattedMessage} from 'react-intl'; +import {useSelector} from 'react-redux'; + +import type {GlobalState} from '@mattermost/types/store'; +import type {Post} from '@mattermost/types/posts'; + +import styled from 'styled-components'; + +import IconAI from 'src/components/ai_icon'; + +const aiPluginID = 'mattermost-ai'; + +type PluginState = GlobalState & { + plugins?: {plugins?: Record}; + [key: string]: unknown; +}; + +export const useAIAvailable = () => { + return useSelector((state: PluginState) => Boolean(state.plugins?.plugins?.[aiPluginID])); +}; + +export const useCallsPostButtonClicked = () => { + return useSelector((state: PluginState) => { + const aiPluginState = state['plugins-' + aiPluginID] as Record | undefined; + const handler = aiPluginState?.callsPostButtonClickedTranscription; + if (typeof handler === 'function') { + return handler as (post: Post) => void; + } + return null; + }); +}; + +const SummaryButton = styled.button` + display: flex; + border: none; + height: 24px; + padding: 4px 10px; + margin-top: 8px; + margin-bottom: 8px; + align-items: center; + justify-content: center; + gap: 6px; + border-radius: 4px; + background: rgba(var(--center-channel-color-rgb), 0.08); + color: rgba(var(--center-channel-color-rgb), 0.64); + font-size: 12px; + font-weight: 600; + line-height: 16px; + + &:hover { + background: rgba(var(--center-channel-color-rgb), 0.12); + color: rgba(var(--center-channel-color-rgb), 0.72); + } + + &:active { + background: rgba(var(--button-bg-rgb), 0.08); + color: var(--button-bg); + } +`; + +type Props = { + post: Post; + messageId: string; + defaultMessage: string; +}; + +export const AISummaryButton = ({post, messageId, defaultMessage}: Props) => { + const aiAvailable = useAIAvailable(); + const callsPostButtonClicked = useCallsPostButtonClicked(); + + const handleClick = useCallback(() => { + if (callsPostButtonClicked) { + callsPostButtonClicked(post); + } + }, [callsPostButtonClicked, post]); + + if (!aiAvailable || !callsPostButtonClicked) { + return null; + } + + return ( + + + + + ); +}; diff --git a/webapp/src/components/post_type_chat.tsx b/webapp/src/components/post_type_chat.tsx new file mode 100644 index 00000000..013b8772 --- /dev/null +++ b/webapp/src/components/post_type_chat.tsx @@ -0,0 +1,37 @@ +// Copyright (c) 2019-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import type {Post} from '@mattermost/types/posts'; + +import {AISummaryButton} from './ai_summary_button'; + +type Props = { + post: Post; +}; + +const renderPostWithMarkdown = (msg: string) => { + const postUtils = (window as any).PostUtils; + if (!postUtils?.formatText || !postUtils?.messageHtmlToComponent) { + return {msg}; + } + + return postUtils.messageHtmlToComponent( + postUtils.formatText(msg, {}), + false, + ); +}; + +export const PostTypeChat = (props: Props) => { + return ( +
+ {renderPostWithMarkdown(props.post.message)} + +
+ ); +}; diff --git a/webapp/src/components/post_type_transcription.tsx b/webapp/src/components/post_type_transcription.tsx new file mode 100644 index 00000000..9092c139 --- /dev/null +++ b/webapp/src/components/post_type_transcription.tsx @@ -0,0 +1,25 @@ +// Copyright (c) 2019-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import type {Post} from '@mattermost/types/posts'; + +import {AISummaryButton} from './ai_summary_button'; + +type Props = { + post: Post; +}; + +export const PostTypeTranscription = (props: Props) => { + return ( +
+ {props.post.message} + +
+ ); +}; diff --git a/webapp/src/components/post_type_zoom/post_type_zoom.jsx b/webapp/src/components/post_type_zoom/post_type_zoom.jsx index dd6cf217..bef47ac2 100644 --- a/webapp/src/components/post_type_zoom/post_type_zoom.jsx +++ b/webapp/src/components/post_type_zoom/post_type_zoom.jsx @@ -152,7 +152,11 @@ export default class PostTypeZoom extends React.PureComponent { const startDate = new Date(post.create_at); const start = formatDate(startDate); - const length = Math.ceil((new Date(post.update_at) - startDate) / 1000 / 60); + const rawEnd = props.meeting_end_time ?? post.update_at; + const endTime = new Date(rawEnd).getTime(); + const startTime = startDate.getTime(); + const durationMs = Number.isFinite(endTime) && endTime >= startTime ? endTime - startTime : 0; + const length = Math.ceil(durationMs / 60000); content = (
diff --git a/webapp/src/components/svg.tsx b/webapp/src/components/svg.tsx new file mode 100644 index 00000000..d0b6026a --- /dev/null +++ b/webapp/src/components/svg.tsx @@ -0,0 +1,13 @@ +// Copyright (c) 2019-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import styled from 'styled-components'; + +// Hat-tip: https://www.pinkdroids.com/blog/svg-react-styled-components/ +const Svg = styled.svg.attrs({ + version: '1.1', + xmlns: 'http://www.w3.org/2000/svg', + xmlnsXlink: 'http://www.w3.org/1999/xlink', +})``; + +export default Svg; diff --git a/webapp/src/index.js b/webapp/src/index.js index 7c2dbff5..dbbf62d8 100644 --- a/webapp/src/index.js +++ b/webapp/src/index.js @@ -7,6 +7,8 @@ import manifest from './manifest'; import ChannelHeaderIcon from './components/channel-header-icon'; import PostTypeZoom from './components/post_type_zoom'; +import {PostTypeTranscription} from './components/post_type_transcription'; +import {PostTypeChat} from './components/post_type_chat'; import {startMeeting} from './actions'; import Client from './client'; import {getPluginURL, getServerRoute} from './selectors'; @@ -50,6 +52,8 @@ class Plugin { ); registry.registerPostTypeComponent('custom_zoom', PostTypeZoom); + registry.registerPostTypeComponent('custom_zoom_transcript', PostTypeTranscription); + registry.registerPostTypeComponent('custom_zoom_chat', PostTypeChat); Client.setServerRoute(getServerRoute(store.getState())); } } diff --git a/webapp/tsconfig.json b/webapp/tsconfig.json index 76616e9f..405ad4c1 100644 --- a/webapp/tsconfig.json +++ b/webapp/tsconfig.json @@ -6,6 +6,7 @@ "dom.iterable", "esnext" ], + "jsx": "react-jsx", "allowJs": true, "skipLibCheck": true, "esModuleInterop": true, diff --git a/webapp/webpack.config.js b/webapp/webpack.config.js index 94559bbc..7be91e0c 100644 --- a/webapp/webpack.config.js +++ b/webapp/webpack.config.js @@ -3,6 +3,8 @@ var path = require('path'); +var webpack = require('webpack'); + module.exports = { entry: [ './src/index.js', @@ -31,6 +33,12 @@ module.exports = { }, ], }, + plugins: [ + new webpack.DefinePlugin({ + // eslint-disable-next-line no-process-env + 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'production'), + }), + ], externals: { react: 'React', 'react-dom': 'ReactDOM',