From 97b3afa7bb78bfabcacce7feb7df7a244bb586db Mon Sep 17 00:00:00 2001 From: Jericho Date: Sun, 5 Jun 2022 10:10:14 -0400 Subject: [PATCH 01/32] Deprecate the Data Compliance API and remove the user_data_retention field. Resolves #203 --- Source/ZoomNet/IZoomClient.cs | 2 ++ Source/ZoomNet/Models/Webhooks/AppDeauthorizedEvent.cs | 8 -------- Source/ZoomNet/Resources/DataCompliance.cs | 2 ++ Source/ZoomNet/Resources/IDataCompliance.cs | 3 +++ Source/ZoomNet/ZoomClient.cs | 1 + 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Source/ZoomNet/IZoomClient.cs b/Source/ZoomNet/IZoomClient.cs index 213f76da..4f54bc6e 100644 --- a/Source/ZoomNet/IZoomClient.cs +++ b/Source/ZoomNet/IZoomClient.cs @@ -1,3 +1,4 @@ +using System; using ZoomNet.Resources; namespace ZoomNet @@ -46,6 +47,7 @@ public interface IZoomClient /// /// The data compliance resource. /// + [Obsolete("The Data Compliance API is deprecated")] IDataCompliance DataCompliance { get; } /// diff --git a/Source/ZoomNet/Models/Webhooks/AppDeauthorizedEvent.cs b/Source/ZoomNet/Models/Webhooks/AppDeauthorizedEvent.cs index f44944f7..3dec048b 100644 --- a/Source/ZoomNet/Models/Webhooks/AppDeauthorizedEvent.cs +++ b/Source/ZoomNet/Models/Webhooks/AppDeauthorizedEvent.cs @@ -26,14 +26,6 @@ public class AppDeauthorizedEvent : Event [JsonPropertyName("client_id")] public string ClientId { get; set; } - /// - /// Gets or sets a value indicating whether the user has authorized you to store their data even after they uninstall your app. - /// If the value is 'false', you must delete the user's data and call the [Data Compliance API](https://marketplace.zoom.us/docs/api-reference/zoom-api/data-compliance/compliance) within ten days of receiving the deauthorization webhook. - /// If the value is 'true', no further action is required on your end. - /// - [JsonPropertyName("user_data_retention")] - public bool CanPreserveUserData { get; set; } - /// /// Gets or sets the time at which the user uninstalled your app. /// diff --git a/Source/ZoomNet/Resources/DataCompliance.cs b/Source/ZoomNet/Resources/DataCompliance.cs index c041b8d8..f9a2e091 100644 --- a/Source/ZoomNet/Resources/DataCompliance.cs +++ b/Source/ZoomNet/Resources/DataCompliance.cs @@ -18,6 +18,7 @@ namespace ZoomNet.Resources /// This resource can only be used when you connect to Zoom using OAuth. It cannot be used with a Jwt connection. /// See Zoom documentation for more information. /// + [Obsolete("The Data Complaince API is deprecated")] public class DataCompliance : IDataCompliance { private readonly Pathoschild.Http.Client.IClient _client; @@ -42,6 +43,7 @@ internal DataCompliance(Pathoschild.Http.Client.IClient client) /// /// The async task. /// + [Obsolete("The Data Complaince API is deprecated")] public Task NotifyAsync(string userId, long accountId, AppDeauthorizedEvent deauthorizationEventReceived, CancellationToken cancellationToken = default) { // Prepare the request (but do not dispatch it yet) diff --git a/Source/ZoomNet/Resources/IDataCompliance.cs b/Source/ZoomNet/Resources/IDataCompliance.cs index ae2a3195..b0519cdd 100644 --- a/Source/ZoomNet/Resources/IDataCompliance.cs +++ b/Source/ZoomNet/Resources/IDataCompliance.cs @@ -1,3 +1,4 @@ +using System; using System.Threading; using System.Threading.Tasks; using ZoomNet.Models.Webhooks; @@ -12,6 +13,7 @@ namespace ZoomNet.Resources /// This resource can only be used when you connect to Zoom using OAuth. It cannot be used with a Jwt connection. /// See Zoom documentation for more information. /// + [Obsolete("The Data Complaince API is deprecated")] public interface IDataCompliance { /// @@ -25,6 +27,7 @@ public interface IDataCompliance /// /// The async task. /// + [Obsolete("The Data Complaince API is deprecated")] Task NotifyAsync(string userId, long accountId, AppDeauthorizedEvent deauthorizationEventReceived, CancellationToken cancellationToken = default); } } diff --git a/Source/ZoomNet/ZoomClient.cs b/Source/ZoomNet/ZoomClient.cs index 5d26eee6..73cb9d23 100644 --- a/Source/ZoomNet/ZoomClient.cs +++ b/Source/ZoomNet/ZoomClient.cs @@ -95,6 +95,7 @@ public static string Version /// /// The data compliance resource. /// + [Obsolete("The Data Compliance API is deprecated")] public IDataCompliance DataCompliance { get; private set; } /// From 066fae6f3b98133c1530adcdb8fde85b7fda3a8c Mon Sep 17 00:00:00 2001 From: Jericho Date: Sun, 5 Jun 2022 10:40:18 -0400 Subject: [PATCH 02/32] Fix typo --- Source/ZoomNet/Json/WebhookEventConverter.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Source/ZoomNet/Json/WebhookEventConverter.cs b/Source/ZoomNet/Json/WebhookEventConverter.cs index 2dac4fbd..010148c4 100644 --- a/Source/ZoomNet/Json/WebhookEventConverter.cs +++ b/Source/ZoomNet/Json/WebhookEventConverter.cs @@ -91,7 +91,7 @@ public override Event Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSe break; case EventType.MeetingRegistrationCancelled: var meetingRegistrationCancelledEvent = payloadJsonProperty.ToObject(options); - meetingRegistrationCancelledEvent.Registrant = payloadJsonProperty.GetProperty("objectregistrant", true).Value.ToObject(); + meetingRegistrationCancelledEvent.Registrant = payloadJsonProperty.GetProperty("object/registrant", true).Value.ToObject(); webHookEvent = meetingRegistrationCancelledEvent; break; case EventType.MeetingRegistrationDenied: From 3de2acec21f87f303264b50f652f7366370aafa2 Mon Sep 17 00:00:00 2001 From: Jericho Date: Sun, 5 Jun 2022 12:34:05 -0400 Subject: [PATCH 03/32] Change MeetingServiceIssueEvent.Issues to an array of strings Resolves #205 --- .../ZoomNet.UnitTests/WebhookParserTests.cs | 44 +++++++++++++- Source/ZoomNet/Extensions/Internal.cs | 60 +++++++++++++------ Source/ZoomNet/Json/WebhookEventConverter.cs | 2 +- .../Webhooks/MeetingServiceIssueEvent.cs | 2 +- 4 files changed, 88 insertions(+), 20 deletions(-) diff --git a/Source/ZoomNet.UnitTests/WebhookParserTests.cs b/Source/ZoomNet.UnitTests/WebhookParserTests.cs index 7002ca14..83bc2023 100644 --- a/Source/ZoomNet.UnitTests/WebhookParserTests.cs +++ b/Source/ZoomNet.UnitTests/WebhookParserTests.cs @@ -30,8 +30,8 @@ public class WebhookParserTests ""settings"": { ""use_pmi"": false, ""alternative_hosts"": """" - } } + } }, ""event_ts"": 1617628462392 }"; @@ -128,6 +128,28 @@ public class WebhookParserTests } }"; + private const string MEETING_SERVICE_ISSUE_WEBHOOK = @" + { + ""event"": ""meeting.alert"", + ""event_ts"": 1626230691572, + ""payload"": { + ""account_id"": ""AAAAAABBBB"", + ""object"": { + ""id"": 1234567890, + ""uuid"": ""4444AAAiAAAAAiAiAiiAii=="", + ""host_id"": ""x1yCzABCDEfg23HiJKl4mN"", + ""topic"": ""My Meeting"", + ""type"": 2, + ""join_time"": ""2021-07-13T21:44:51Z"", + ""timezone"": ""America/Los_Angeles"", + ""duration"": 60, + ""issues"": [ + ""Unstable audio quality"" + ] + } + } + }"; + #endregion [Fact] @@ -239,5 +261,25 @@ public void MeetingSharingStarted() parsedMeeting.Duration.ShouldBe(60); parsedMeeting.Timezone.ShouldBe("America/Los_Angeles"); } + + [Fact] + public void MeetingServiceIssue() + { + var parsedEvent = (MeetingServiceIssueEvent)new WebhookParser().ParseEventWebhook(MEETING_SERVICE_ISSUE_WEBHOOK); + + parsedEvent.EventType.ShouldBe(EventType.MeetingServiceIssue); + parsedEvent.AccountId.ShouldBe("AAAAAABBBB"); + parsedEvent.Timestamp.ShouldBe(new DateTime(2021, 7, 14, 2, 44, 51, 572, DateTimeKind.Utc)); + parsedEvent.Issues.ShouldBe(new[] { "Unstable audio quality" }); + + parsedEvent.Meeting.GetType().ShouldBe(typeof(ScheduledMeeting)); + var parsedMeeting = (ScheduledMeeting)parsedEvent.Meeting; + parsedMeeting.Id.ShouldBe(1234567890L); + parsedMeeting.Topic.ShouldBe("My Meeting"); + parsedMeeting.Uuid.ShouldBe("4444AAAiAAAAAiAiAiiAii=="); + parsedMeeting.HostId.ShouldBe("x1yCzABCDEfg23HiJKl4mN"); + parsedMeeting.Duration.ShouldBe(60); + parsedMeeting.Timezone.ShouldBe("America/Los_Angeles"); + } } } diff --git a/Source/ZoomNet/Extensions/Internal.cs b/Source/ZoomNet/Extensions/Internal.cs index 0c96affe..6ac934c9 100644 --- a/Source/ZoomNet/Extensions/Internal.cs +++ b/Source/ZoomNet/Extensions/Internal.cs @@ -1,5 +1,6 @@ using Pathoschild.Http.Client; using System; +using System.Collections; using System.Collections.Generic; using System.ComponentModel; using System.Globalization; @@ -996,25 +997,50 @@ private static T GetPropertyValue(this JsonElement element, string name, T de typeOfT = Nullable.GetUnderlyingType(typeOfT); } + if (typeOfT.IsArray) + { + var elementType = typeOfT.GetElementType(); + + var getElementValue = typeof(Internal) + .GetMethod(nameof(Internal.GetElementValue), BindingFlags.Static | BindingFlags.NonPublic) + .MakeGenericMethod(elementType); + + var arrayList = new ArrayList(property.Value.GetArrayLength()); + foreach (var arrayElement in property.Value.EnumerateArray()) + { + var elementValue = getElementValue.Invoke(null, new object[] { arrayElement }); + arrayList.Add(elementValue); + } + + return (T)Convert.ChangeType(arrayList.ToArray(elementType), typeof(T)); + } + + return property.Value.GetElementValue(); + } + + private static T GetElementValue(this JsonElement element) + { + var typeOfT = typeof(T); + switch (typeOfT) { - case Type boolType when boolType == typeof(bool): return (T)(object)property.Value.GetBoolean(); - case Type strType when strType == typeof(string): return (T)(object)property.Value.GetString(); - case Type bytesType when bytesType == typeof(byte[]): return (T)(object)property.Value.GetBytesFromBase64(); - case Type sbyteType when sbyteType == typeof(sbyte): return (T)(object)property.Value.GetSByte(); - case Type byteType when byteType == typeof(byte): return (T)(object)property.Value.GetByte(); - case Type shortType when shortType == typeof(short): return (T)(object)property.Value.GetInt16(); - case Type ushortType when ushortType == typeof(ushort): return (T)(object)property.Value.GetUInt16(); - case Type intType when intType == typeof(int): return (T)(object)property.Value.GetInt32(); - case Type uintType when uintType == typeof(uint): return (T)(object)property.Value.GetUInt32(); - case Type longType when longType == typeof(long): return (T)(object)property.Value.GetInt64(); - case Type ulongType when ulongType == typeof(ulong): return (T)(object)property.Value.GetUInt64(); - case Type doubleType when doubleType == typeof(double): return (T)(object)property.Value.GetDouble(); - case Type floatType when floatType == typeof(float): return (T)(object)property.Value.GetSingle(); - case Type decimalType when decimalType == typeof(decimal): return (T)(object)property.Value.GetDecimal(); - case Type datetimeType when datetimeType == typeof(DateTime): return (T)(object)property.Value.GetDateTime(); - case Type offsetType when offsetType == typeof(DateTimeOffset): return (T)(object)property.Value.GetDateTimeOffset(); - case Type guidType when guidType == typeof(Guid): return (T)(object)property.Value.GetGuid(); + case Type boolType when boolType == typeof(bool): return (T)(object)element.GetBoolean(); + case Type strType when strType == typeof(string): return (T)(object)element.GetString(); + case Type bytesType when bytesType == typeof(byte[]): return (T)(object)element.GetBytesFromBase64(); + case Type sbyteType when sbyteType == typeof(sbyte): return (T)(object)element.GetSByte(); + case Type byteType when byteType == typeof(byte): return (T)(object)element.GetByte(); + case Type shortType when shortType == typeof(short): return (T)(object)element.GetInt16(); + case Type ushortType when ushortType == typeof(ushort): return (T)(object)element.GetUInt16(); + case Type intType when intType == typeof(int): return (T)(object)element.GetInt32(); + case Type uintType when uintType == typeof(uint): return (T)(object)element.GetUInt32(); + case Type longType when longType == typeof(long): return (T)(object)element.GetInt64(); + case Type ulongType when ulongType == typeof(ulong): return (T)(object)element.GetUInt64(); + case Type doubleType when doubleType == typeof(double): return (T)(object)element.GetDouble(); + case Type floatType when floatType == typeof(float): return (T)(object)element.GetSingle(); + case Type decimalType when decimalType == typeof(decimal): return (T)(object)element.GetDecimal(); + case Type datetimeType when datetimeType == typeof(DateTime): return (T)(object)element.GetDateTime(); + case Type offsetType when offsetType == typeof(DateTimeOffset): return (T)(object)element.GetDateTimeOffset(); + case Type guidType when guidType == typeof(Guid): return (T)(object)element.GetGuid(); default: throw new ArgumentException($"Unsable to map {typeof(T).FullName} to a corresponding JSON type", nameof(T)); } } diff --git a/Source/ZoomNet/Json/WebhookEventConverter.cs b/Source/ZoomNet/Json/WebhookEventConverter.cs index 010148c4..98088a36 100644 --- a/Source/ZoomNet/Json/WebhookEventConverter.cs +++ b/Source/ZoomNet/Json/WebhookEventConverter.cs @@ -33,7 +33,7 @@ public override Event Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSe break; case EventType.MeetingServiceIssue: var meetingServiceIssueEvent = payloadJsonProperty.ToObject(options); - meetingServiceIssueEvent.Issues = payloadJsonProperty.GetPropertyValue("object/issues", string.Empty); + meetingServiceIssueEvent.Issues = payloadJsonProperty.GetPropertyValue("object/issues", Array.Empty()); webHookEvent = meetingServiceIssueEvent; break; case EventType.MeetingCreated: diff --git a/Source/ZoomNet/Models/Webhooks/MeetingServiceIssueEvent.cs b/Source/ZoomNet/Models/Webhooks/MeetingServiceIssueEvent.cs index b74d77a6..bbfb3a01 100644 --- a/Source/ZoomNet/Models/Webhooks/MeetingServiceIssueEvent.cs +++ b/Source/ZoomNet/Models/Webhooks/MeetingServiceIssueEvent.cs @@ -14,6 +14,6 @@ public class MeetingServiceIssueEvent : MeetingEvent /// /// Gets or sets the issues that occured during the meeting. /// - public string Issues { get; set; } + public string[] Issues { get; set; } } } From 06adeb65fecd3632491f1a7aae78e2a1faf13a4b Mon Sep 17 00:00:00 2001 From: Jericho Date: Sun, 5 Jun 2022 12:35:20 -0400 Subject: [PATCH 04/32] Fix typo --- Source/ZoomNet/Models/Webhooks/EventType.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Source/ZoomNet/Models/Webhooks/EventType.cs b/Source/ZoomNet/Models/Webhooks/EventType.cs index 4f3f019d..b1e463b4 100644 --- a/Source/ZoomNet/Models/Webhooks/EventType.cs +++ b/Source/ZoomNet/Models/Webhooks/EventType.cs @@ -21,7 +21,7 @@ public enum EventType /// /// A service issue has been encountered during a meeting. /// - [EnumMember(Value = "meeting.alerts")] + [EnumMember(Value = "meeting.alert")] MeetingServiceIssue, /// From 40d736d29984137315e611c17483083573d9c405 Mon Sep 17 00:00:00 2001 From: Jericho Date: Sun, 5 Jun 2022 12:36:28 -0400 Subject: [PATCH 05/32] Update xml comment to reflect the fact that alternative hosts are separated by a semi-colon rather than a comma --- Source/ZoomNet/Models/MeetingSettings.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Source/ZoomNet/Models/MeetingSettings.cs b/Source/ZoomNet/Models/MeetingSettings.cs index 9b8d5b39..32f0bafb 100644 --- a/Source/ZoomNet/Models/MeetingSettings.cs +++ b/Source/ZoomNet/Models/MeetingSettings.cs @@ -92,7 +92,7 @@ public class MeetingSettings public string EnforceLoginDomains { get; set; } /// - /// Gets or sets the value indicating alternative hosts emails or IDs. Multiple value separated by comma. + /// Gets or sets the value indicating alternative hosts email addresses or IDs. Multiple value separated by semicolon. /// [JsonPropertyName("alternative_hosts")] public string AlternativeHosts { get; set; } From ddc1969185079c9a5de538b36eb19b5e48cdf9c7 Mon Sep 17 00:00:00 2001 From: Jericho Date: Sun, 5 Jun 2022 12:51:02 -0400 Subject: [PATCH 06/32] (GH-205) Fix GetPropertyValue extension method when the desired type is nullable --- Source/ZoomNet/Extensions/Internal.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Source/ZoomNet/Extensions/Internal.cs b/Source/ZoomNet/Extensions/Internal.cs index 6ac934c9..b5117834 100644 --- a/Source/ZoomNet/Extensions/Internal.cs +++ b/Source/ZoomNet/Extensions/Internal.cs @@ -994,13 +994,17 @@ private static T GetPropertyValue(this JsonElement element, string name, T de if (typeOfT.IsGenericType && typeOfT.GetGenericTypeDefinition() == typeof(Nullable<>)) { - typeOfT = Nullable.GetUnderlyingType(typeOfT); + var underlyingType = Nullable.GetUnderlyingType(typeOfT); + var getElementValue = typeof(Internal) + .GetMethod(nameof(Internal.GetElementValue), BindingFlags.Static | BindingFlags.NonPublic) + .MakeGenericMethod(underlyingType); + + return (T)getElementValue.Invoke(null, new object[] { property.Value }); } if (typeOfT.IsArray) { var elementType = typeOfT.GetElementType(); - var getElementValue = typeof(Internal) .GetMethod(nameof(Internal.GetElementValue), BindingFlags.Static | BindingFlags.NonPublic) .MakeGenericMethod(elementType); From 4a6643c52eb197de2f42ff13fee764d655f18cfd Mon Sep 17 00:00:00 2001 From: Jericho Date: Tue, 7 Jun 2022 10:28:04 -0400 Subject: [PATCH 07/32] Update the possible values for ScreenshareContentType based on documentation found here: https://marketplace.zoom.us/docs/api-reference/zoom-api/events/#/paths/meeting.breakout_room_sharing_ended/post --- .../ZoomNet/Models/ScreenshareContentType.cs | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/Source/ZoomNet/Models/ScreenshareContentType.cs b/Source/ZoomNet/Models/ScreenshareContentType.cs index ee2d821d..58bafd93 100644 --- a/Source/ZoomNet/Models/ScreenshareContentType.cs +++ b/Source/ZoomNet/Models/ScreenshareContentType.cs @@ -23,6 +23,24 @@ public enum ScreenshareContentType /// Desktop. /// [EnumMember(Value = "desktop")] - Desktop + Desktop, + + /// + /// Airplay. + /// + [EnumMember(Value = "airplay")] + Airplay, + + /// + /// Camera. + /// + [EnumMember(Value = "camera")] + Camera, + + /// + /// An unrecognized application, such as a third party app. + /// + [EnumMember(Value = "unknown")] + Unknown } } From 4f72de8832aa65a87c87d7f428ac1dccff38f7e6 Mon Sep 17 00:00:00 2001 From: Jericho Date: Tue, 7 Jun 2022 13:14:05 -0400 Subject: [PATCH 08/32] Add SendFileAsync and UploadFileAsync to the Chat resource Resolves #207 --- Source/ZoomNet.IntegrationTests/Tests/Chat.cs | 16 ++++++++ Source/ZoomNet/Resources/Chat.cs | 41 +++++++++++++++++-- Source/ZoomNet/Resources/IChat.cs | 38 +++++++++++++++++ 3 files changed, 92 insertions(+), 3 deletions(-) diff --git a/Source/ZoomNet.IntegrationTests/Tests/Chat.cs b/Source/ZoomNet.IntegrationTests/Tests/Chat.cs index 499006e3..c4f0c733 100644 --- a/Source/ZoomNet.IntegrationTests/Tests/Chat.cs +++ b/Source/ZoomNet.IntegrationTests/Tests/Chat.cs @@ -1,3 +1,4 @@ +using System; using System.IO; using System.Linq; using System.Threading; @@ -50,6 +51,21 @@ public async Task RunAsync(User myUser, string[] myPermissions, IZoomClient clie await log.WriteLineAsync($"Message \"{messageId}\" sent").ConfigureAwait(false); await Task.Delay(1000, cancellationToken).ConfigureAwait(false); // Allow the Zoom system to process this message and avoid subsequent "message doesn't exist" error messages + // SEND A FILE TO THE CHANNEL + var samplePicturesFolder = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyPictures), "Samples"); + if (Directory.Exists(samplePicturesFolder)) + { + var samplePicture = Directory.EnumerateFiles(samplePicturesFolder, "*.jpg").FirstOrDefault(); + if (!string.IsNullOrEmpty(samplePicture)) + { + using (var fileStream = File.OpenRead(samplePicture)) + { + var fileId = await client.Chat.SendFileAsync(null, "me", null, channel.Id, Path.GetFileName(samplePicture), fileStream, cancellationToken).ConfigureAwait(false); + await log.WriteLineAsync($"File {fileId} sent").ConfigureAwait(false); + } + } + } + // UPDATE THE MESSAGE await client.Chat.UpdateMessageToChannelAsync(messageId, channel.Id, "This is an updated message from integration testing", null, cancellationToken).ConfigureAwait(false); await log.WriteLineAsync($"Message \"{messageId}\" updated").ConfigureAwait(false); diff --git a/Source/ZoomNet/Resources/Chat.cs b/Source/ZoomNet/Resources/Chat.cs index d132e34a..65f12104 100644 --- a/Source/ZoomNet/Resources/Chat.cs +++ b/Source/ZoomNet/Resources/Chat.cs @@ -2,7 +2,9 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.IO; using System.Linq; +using System.Net.Http; using System.Text.Json.Nodes; using System.Threading; using System.Threading.Tasks; @@ -431,11 +433,44 @@ public Task DeleteMessageToChannelAsync(string messageId, string userId, string return DeleteMessageAsync(messageId, userId, null, channelId, cancellationToken); } - private Task SendMessageAsync(string userId, string recipientEmail, string channelId, string message, IEnumerable mentions, CancellationToken cancellationToken) + /// + public Task SendFileAsync(string messageId, string userId, string recipientId, string channelId, string fileName, Stream fileData, CancellationToken cancellationToken = default) { - Debug.Assert(recipientEmail != null || channelId != null, "You must provide either recipientEmail or channelId"); - Debug.Assert(recipientEmail == null || channelId == null, "You can't provide both recipientEmail and channelId"); + return _client + .PostAsync($"https://file.zoom.us/v2/chat/users/{userId}/messages/files") + .WithBody(bodyBuilder => + { + // The file name as well as the name of the other 'parts' in the request must be quoted otherwise the Zoom API would return the following error message: Invalid 'Content-Disposition' in multipart form + var content = new MultipartFormDataContent(); + content.Add(new StreamContent(fileData), "files", $"\"{fileName}\""); + if (!string.IsNullOrEmpty(messageId)) content.Add(new StringContent(messageId), "\"reply_main_message_id\""); + if (!string.IsNullOrEmpty(channelId)) content.Add(new StringContent(channelId), "\"to_channel\""); + if (!string.IsNullOrEmpty(recipientId)) content.Add(new StringContent(recipientId), "\"to_contact\""); + + return content; + }) + .WithCancellationToken(cancellationToken) + .AsObject("id"); + } + /// + public Task UploadFileAsync(string userId, string fileName, Stream fileData, CancellationToken cancellationToken = default) + { + return _client + .PostAsync($"https://file.zoom.us/v2/chat/users/{userId}/files") + .WithBody(bodyBuilder => + { + // The file name must be quoted otherwise the Zoom API would return the following error message: Invalid 'Content-Disposition' in multipart form + var content = new MultipartFormDataContent(); + content.Add(new StreamContent(fileData), "file", $"\"{fileName}\""); + return content; + }) + .WithCancellationToken(cancellationToken) + .AsObject("id"); + } + + private Task SendMessageAsync(string userId, string recipientEmail, string channelId, string message, IEnumerable mentions, CancellationToken cancellationToken) + { var data = new JsonObject { { "message", message }, diff --git a/Source/ZoomNet/Resources/IChat.cs b/Source/ZoomNet/Resources/IChat.cs index 6f9177e0..a64be4b2 100644 --- a/Source/ZoomNet/Resources/IChat.cs +++ b/Source/ZoomNet/Resources/IChat.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.IO; using System.Threading; using System.Threading.Tasks; using ZoomNet.Models; @@ -269,5 +270,42 @@ public interface IChat /// The async task. /// Task DeleteMessageToChannelAsync(string messageId, string userId, string channelId, CancellationToken cancellationToken = default); + + /// + /// Send a file on Zoom to either an individual user in your contact list or a channel of which you are a member. + /// + /// The reply message's ID. + /// The unique identifier of the sender. + /// The unique identifier of the contact to whom you would like to send the file. + /// The unique identifier of the channel to which to send the file. + /// The file name. + /// The binary data. + /// The cancellation token. + /// + /// The message ID. + /// + /// + /// Zoom Cloud Storage will store the files sent through this API. + /// If you do not use Zoom Cloud Storage, Zoom Cloud will temporarily store these files for 7 day + /// You can only send a maximum of 16 megabytes for images and 20 megabytes for all other file types. + /// + Task SendFileAsync(string messageId, string userId, string recipientId, string channelId, string fileName, Stream fileData, CancellationToken cancellationToken = default); + + /// + /// Upload a file to chat. + /// + /// The user Id. + /// The file name. + /// The binary data. + /// The cancellation token. + /// + /// The file ID. + /// + /// + /// Zoom Cloud Storage will store the files sent through this API. + /// If you do not use Zoom Cloud Storage, Zoom Cloud will temporarily store these files for 7 day + /// You can only send a maximum of 16 megabytes for images and 20 megabytes for all other file types. + /// + Task UploadFileAsync(string userId, string fileName, Stream fileData, CancellationToken cancellationToken = default); } } From 805cd600d77f8edbfb205eea8f5a79416596a0a6 Mon Sep 17 00:00:00 2001 From: Jericho Date: Tue, 7 Jun 2022 20:20:03 -0400 Subject: [PATCH 09/32] (GH-208) Add virtualBackgroundId property to the Panelist model class --- Source/ZoomNet/Models/Panelist.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Source/ZoomNet/Models/Panelist.cs b/Source/ZoomNet/Models/Panelist.cs index 566cb396..6fab2d37 100644 --- a/Source/ZoomNet/Models/Panelist.cs +++ b/Source/ZoomNet/Models/Panelist.cs @@ -33,5 +33,11 @@ public class Panelist /// [JsonPropertyName("join_url")] public string JoinUrl { get; set; } + + /// + /// Gets or sets the panelist's virtual background id. + /// + [JsonPropertyName("virtual_background_id")] + public string VirtualBackgroundId { get; set; } } } From 6d90af0de67f7e278a6b0167336d237a2f42c6bd Mon Sep 17 00:00:00 2001 From: Jericho Date: Tue, 7 Jun 2022 20:22:10 -0400 Subject: [PATCH 10/32] Add virtualBackgroundId parameter to the Webinar.AddPanelistAsync method (and also AddPanelistsAsync) Resolves #208 --- Source/ZoomNet/Resources/IWebinars.cs | 5 +++-- Source/ZoomNet/Resources/Webinars.cs | 16 ++++++++++++---- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/Source/ZoomNet/Resources/IWebinars.cs b/Source/ZoomNet/Resources/IWebinars.cs index c39356a8..1e53afa9 100644 --- a/Source/ZoomNet/Resources/IWebinars.cs +++ b/Source/ZoomNet/Resources/IWebinars.cs @@ -182,11 +182,12 @@ public interface IWebinars /// The webinar ID. /// Panelist's email address. /// Panelist's full name. + /// The virtual background ID to bind. /// The cancellation token. /// /// The async task. /// - Task AddPanelistAsync(long webinarId, string email, string fullName, CancellationToken cancellationToken = default); + Task AddPanelistAsync(long webinarId, string email, string fullName, string virtualBackgroundId = null, CancellationToken cancellationToken = default); /// /// Add multiple panelists to a webinar. @@ -197,7 +198,7 @@ public interface IWebinars /// /// The async task. /// - Task AddPanelistsAsync(long webinarId, IEnumerable<(string Email, string FullName)> panelists, CancellationToken cancellationToken = default); + Task AddPanelistsAsync(long webinarId, IEnumerable<(string Email, string FullName, string VirtualBackgroundId)> panelists, CancellationToken cancellationToken = default); /// /// Remove a single panelist from a webinar. diff --git a/Source/ZoomNet/Resources/Webinars.cs b/Source/ZoomNet/Resources/Webinars.cs index b80f1cdc..6d944b8d 100644 --- a/Source/ZoomNet/Resources/Webinars.cs +++ b/Source/ZoomNet/Resources/Webinars.cs @@ -357,13 +357,14 @@ public Task GetPanelistsAsync(long webinarId, CancellationToken canc /// The webinar ID. /// Panelist's email address. /// Panelist's full name. + /// The virtual background ID to bind. /// The cancellation token. /// /// The async task. /// - public Task AddPanelistAsync(long webinarId, string email, string fullName, CancellationToken cancellationToken = default) + public Task AddPanelistAsync(long webinarId, string email, string fullName, string virtualBackgroundId = null, CancellationToken cancellationToken = default) { - return AddPanelistsAsync(webinarId, new (string Email, string FullName)[] { (email, fullName) }, cancellationToken); + return AddPanelistsAsync(webinarId, new (string Email, string FullName, string VirtualBackgroundId)[] { (email, fullName, virtualBackgroundId) }, cancellationToken); } /// @@ -375,11 +376,18 @@ public Task AddPanelistAsync(long webinarId, string email, string fullName, Canc /// /// The async task. /// - public Task AddPanelistsAsync(long webinarId, IEnumerable<(string Email, string FullName)> panelists, CancellationToken cancellationToken = default) + public Task AddPanelistsAsync(long webinarId, IEnumerable<(string Email, string FullName, string VirtualBackgroundId)> panelists, CancellationToken cancellationToken = default) { var data = new JsonObject { - { "panelists", panelists.Select(p => new JsonObject { { "email", p.Email }, { "name", p.FullName } }) } + { + "panelists", panelists.Select(p => new JsonObject + { + { "email", p.Email }, + { "name", p.FullName }, + { "virtual_background_id", p.VirtualBackgroundId }, + }) + } }; return _client From 55c63370845abe80cf89a8c000ee06fd9d2ffcec Mon Sep 17 00:00:00 2001 From: Jericho Date: Tue, 7 Jun 2022 21:54:14 -0400 Subject: [PATCH 11/32] Add ability to reply to a chat message and also to attach files with a message Resolves #209 --- Source/ZoomNet.IntegrationTests/Tests/Chat.cs | 43 ++++++++++++----- Source/ZoomNet/Extensions/Public.cs | 16 ++++--- Source/ZoomNet/Resources/Chat.cs | 48 +++++++------------ Source/ZoomNet/Resources/IChat.cs | 8 +++- 4 files changed, 63 insertions(+), 52 deletions(-) diff --git a/Source/ZoomNet.IntegrationTests/Tests/Chat.cs b/Source/ZoomNet.IntegrationTests/Tests/Chat.cs index c4f0c733..3a5eb970 100644 --- a/Source/ZoomNet.IntegrationTests/Tests/Chat.cs +++ b/Source/ZoomNet.IntegrationTests/Tests/Chat.cs @@ -47,29 +47,46 @@ public async Task RunAsync(User myUser, string[] myPermissions, IZoomClient clie await log.WriteLineAsync($"Account channel \"{channel.Id}\" has {paginatedMembers.TotalRecords} members").ConfigureAwait(false); // SEND A MESSAGE TO THE CHANNEL - var messageId = await client.Chat.SendMessageToChannelAsync(channel.Id, "This is a test from integration test", null, cancellationToken).ConfigureAwait(false); + var messageId = await client.Chat.SendMessageToChannelAsync(channel.Id, "This is a test from integration test", null, null, null, cancellationToken).ConfigureAwait(false); await log.WriteLineAsync($"Message \"{messageId}\" sent").ConfigureAwait(false); await Task.Delay(1000, cancellationToken).ConfigureAwait(false); // Allow the Zoom system to process this message and avoid subsequent "message doesn't exist" error messages - // SEND A FILE TO THE CHANNEL + // UPDATE THE MESSAGE + await client.Chat.UpdateMessageToChannelAsync(messageId, channel.Id, "This is an updated message from integration testing.\nThis message contains simple text.", null, cancellationToken).ConfigureAwait(false); + await log.WriteLineAsync($"Message \"{messageId}\" updated").ConfigureAwait(false); + + // REPLY TO THE MESSAGE + messageId = await client.Chat.SendMessageToChannelAsync(channel.Id, "This is a reply to the message.", messageId, null, null, cancellationToken).ConfigureAwait(false); + await log.WriteLineAsync($"Reply \"{messageId}\" sent").ConfigureAwait(false); + await Task.Delay(1000, cancellationToken).ConfigureAwait(false); // Allow the Zoom system to process this message and avoid subsequent "message doesn't exist" error messages + + // Check that this computer has a folder containing sample images which we can use to send files to the channel var samplePicturesFolder = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyPictures), "Samples"); if (Directory.Exists(samplePicturesFolder)) { - var samplePicture = Directory.EnumerateFiles(samplePicturesFolder, "*.jpg").FirstOrDefault(); - if (!string.IsNullOrEmpty(samplePicture)) + var rnd = new Random(); + var samplePictures = Directory.EnumerateFiles(samplePicturesFolder, "*.jpg"); + if (samplePictures.Any()) { - using (var fileStream = File.OpenRead(samplePicture)) - { - var fileId = await client.Chat.SendFileAsync(null, "me", null, channel.Id, Path.GetFileName(samplePicture), fileStream, cancellationToken).ConfigureAwait(false); - await log.WriteLineAsync($"File {fileId} sent").ConfigureAwait(false); - } + // SEND A FILE TO THE CHANNEL + var samplePicture = samplePictures.ElementAt(rnd.Next(0, samplePictures.Count())); + using var fileToSendStream = File.OpenRead(samplePicture); + var sentFileId = await client.Chat.SendFileAsync(null, "me", null, channel.Id, Path.GetFileName(samplePicture), fileToSendStream, cancellationToken).ConfigureAwait(false); + await log.WriteLineAsync($"File {sentFileId} sent").ConfigureAwait(false); + + // UPLOAD A FILE + samplePicture = samplePictures.ElementAt(rnd.Next(0, samplePictures.Count())); + using var fileToUploadStream = File.OpenRead(samplePicture); + var uploadedFileId = await client.Chat.UploadFileAsync("me", Path.GetFileName(samplePicture), fileToUploadStream, cancellationToken).ConfigureAwait(false); + await log.WriteLineAsync($"File {uploadedFileId} uploaded").ConfigureAwait(false); + + // SEND A MESSAGE WITH ATTACHMENT + messageId = await client.Chat.SendMessageToChannelAsync(channel.Id, "This message has an attachment", null, new[] { uploadedFileId }, null, cancellationToken).ConfigureAwait(false); + await log.WriteLineAsync($"Message \"{messageId}\" sent with attachment").ConfigureAwait(false); + await Task.Delay(1000, cancellationToken).ConfigureAwait(false); // Allow the Zoom system to process this message and avoid subsequent "message doesn't exist" error messages } } - // UPDATE THE MESSAGE - await client.Chat.UpdateMessageToChannelAsync(messageId, channel.Id, "This is an updated message from integration testing", null, cancellationToken).ConfigureAwait(false); - await log.WriteLineAsync($"Message \"{messageId}\" updated").ConfigureAwait(false); - // RETRIEVE LIST OF MESSAGES var paginatedMessages = await client.Chat.GetMessagesToChannelAsync(channel.Id, 100, null, cancellationToken).ConfigureAwait(false); await log.WriteLineAsync($"There are {paginatedMessages.TotalRecords} messages in channel \"{channel.Id}\"").ConfigureAwait(false); diff --git a/Source/ZoomNet/Extensions/Public.cs b/Source/ZoomNet/Extensions/Public.cs index 23fdafa8..2dca00bb 100644 --- a/Source/ZoomNet/Extensions/Public.cs +++ b/Source/ZoomNet/Extensions/Public.cs @@ -100,14 +100,16 @@ public static Task LeaveChannelAsync(this IChat chatResource, string channelId, /// The chat resource. /// The email address of the contact to whom you would like to send the message. /// The message. + /// The reply message's ID. + /// A list of the file IDs to send. This field only accepts a maximum of six file IDs. /// Mentions. /// The cancellation token. /// /// The message Id. /// - public static Task SendMessageToContactAsync(this IChat chatResource, string recipientEmail, string message, IEnumerable mentions = null, CancellationToken cancellationToken = default) + public static Task SendMessageToContactAsync(this IChat chatResource, string recipientEmail, string message, string replyMessageId = null, IEnumerable fileIds = null, IEnumerable mentions = null, CancellationToken cancellationToken = default) { - return chatResource.SendMessageToContactAsync("me", recipientEmail, message, mentions, cancellationToken); + return chatResource.SendMessageToContactAsync("me", recipientEmail, message, replyMessageId, fileIds, mentions, cancellationToken); } /// @@ -116,14 +118,16 @@ public static Task SendMessageToContactAsync(this IChat chatResource, st /// The chat resource. /// The channel Id. /// The message. + /// The reply message's ID. + /// A list of the file IDs to send. This field only accepts a maximum of six file IDs. /// Mentions. /// The cancellation token. /// /// The message Id. /// - public static Task SendMessageToChannelAsync(this IChat chatResource, string channelId, string message, IEnumerable mentions = null, CancellationToken cancellationToken = default) + public static Task SendMessageToChannelAsync(this IChat chatResource, string channelId, string message, string replyMessageId = null, IEnumerable fileIds = null, IEnumerable mentions = null, CancellationToken cancellationToken = default) { - return chatResource.SendMessageToChannelAsync("me", channelId, message, mentions, cancellationToken); + return chatResource.SendMessageToChannelAsync("me", channelId, message, replyMessageId, fileIds, mentions, cancellationToken); } /// @@ -252,7 +256,7 @@ public static async Task ParseEventWebhookAsync(IWebhookParser parser, St /// public static Task DownloadFileAsync(this ICloudRecordings cloudRecordingsResource, RecordingFile recordingFile, CancellationToken cancellationToken = default) { - return cloudRecordingsResource.DownloadFileAsync(recordingFile.DownloadUrl); + return cloudRecordingsResource.DownloadFileAsync(recordingFile.DownloadUrl, cancellationToken); } /// @@ -267,7 +271,7 @@ public static Task DownloadFileAsync(this ICloudRecordings cloudRecordin /// public static Task InviteParticipantAsync(this IMeetings meetingsResource, long meetingId, string emailAddress, CancellationToken cancellationToken = default) { - return meetingsResource.InviteParticipantsAsync(meetingId, new[] { emailAddress }); + return meetingsResource.InviteParticipantsAsync(meetingId, new[] { emailAddress }, cancellationToken); } } } diff --git a/Source/ZoomNet/Resources/Chat.cs b/Source/ZoomNet/Resources/Chat.cs index 65f12104..7ef2126b 100644 --- a/Source/ZoomNet/Resources/Chat.cs +++ b/Source/ZoomNet/Resources/Chat.cs @@ -305,36 +305,16 @@ public Task JoinChannelAsync(string channelId, CancellationToken cancell .AsObject("id"); } - /// - /// Send a message to a user on on the sender's contact list. - /// - /// The unique identifier of the sender. - /// The email address of the contact to whom you would like to send the message. - /// The message. - /// Mentions. - /// The cancellation token. - /// - /// The message Id. - /// - public Task SendMessageToContactAsync(string userId, string recipientEmail, string message, IEnumerable mentions = null, CancellationToken cancellationToken = default) + /// + public Task SendMessageToContactAsync(string userId, string recipientEmail, string message, string replyMessageId = null, IEnumerable fileIds = null, IEnumerable mentions = null, CancellationToken cancellationToken = default) { - return SendMessageAsync(userId, recipientEmail, null, message, mentions, cancellationToken); + return SendMessageAsync(userId, recipientEmail, null, message, replyMessageId, fileIds, mentions, cancellationToken); } - /// - /// Send a message to a channel of which the sender is a member. - /// - /// The unique identifier of the sender. - /// The channel Id. - /// The message. - /// Mentions. - /// The cancellation token. - /// - /// The message Id. - /// - public Task SendMessageToChannelAsync(string userId, string channelId, string message, IEnumerable mentions = null, CancellationToken cancellationToken = default) + /// + public Task SendMessageToChannelAsync(string userId, string channelId, string message, string replyMessageId = null, IEnumerable fileIds = null, IEnumerable mentions = null, CancellationToken cancellationToken = default) { - return SendMessageAsync(userId, null, channelId, message, mentions, cancellationToken); + return SendMessageAsync(userId, null, channelId, message, replyMessageId, fileIds, mentions, cancellationToken); } /// @@ -441,8 +421,10 @@ public Task SendFileAsync(string messageId, string userId, string recipi .WithBody(bodyBuilder => { // The file name as well as the name of the other 'parts' in the request must be quoted otherwise the Zoom API would return the following error message: Invalid 'Content-Disposition' in multipart form - var content = new MultipartFormDataContent(); - content.Add(new StreamContent(fileData), "files", $"\"{fileName}\""); + var content = new MultipartFormDataContent + { + { new StreamContent(fileData), "files", $"\"{fileName}\"" } + }; if (!string.IsNullOrEmpty(messageId)) content.Add(new StringContent(messageId), "\"reply_main_message_id\""); if (!string.IsNullOrEmpty(channelId)) content.Add(new StringContent(channelId), "\"to_channel\""); if (!string.IsNullOrEmpty(recipientId)) content.Add(new StringContent(recipientId), "\"to_contact\""); @@ -461,19 +443,23 @@ public Task UploadFileAsync(string userId, string fileName, Stream fileD .WithBody(bodyBuilder => { // The file name must be quoted otherwise the Zoom API would return the following error message: Invalid 'Content-Disposition' in multipart form - var content = new MultipartFormDataContent(); - content.Add(new StreamContent(fileData), "file", $"\"{fileName}\""); + var content = new MultipartFormDataContent + { + { new StreamContent(fileData), "file", $"\"{fileName}\"" } + }; return content; }) .WithCancellationToken(cancellationToken) .AsObject("id"); } - private Task SendMessageAsync(string userId, string recipientEmail, string channelId, string message, IEnumerable mentions, CancellationToken cancellationToken) + private Task SendMessageAsync(string userId, string recipientEmail, string channelId, string message, string replyMessageId = null, IEnumerable fileIds = null, IEnumerable mentions = null, CancellationToken cancellationToken = default) { var data = new JsonObject { { "message", message }, + { "file_ids", fileIds?.ToArray() }, + { "reply_main_message_id", replyMessageId }, { "to_contact", recipientEmail }, { "to_channel", channelId }, { "at_items", mentions } diff --git a/Source/ZoomNet/Resources/IChat.cs b/Source/ZoomNet/Resources/IChat.cs index a64be4b2..f5b9c87f 100644 --- a/Source/ZoomNet/Resources/IChat.cs +++ b/Source/ZoomNet/Resources/IChat.cs @@ -173,12 +173,14 @@ public interface IChat /// The unique identifier of the sender. /// The email address of the contact to whom you would like to send the message. /// The message. + /// The reply message's ID. + /// A list of the file IDs to send. This field only accepts a maximum of six file IDs. /// Mentions. /// The cancellation token. /// /// The message Id. /// - Task SendMessageToContactAsync(string userId, string recipientEmail, string message, IEnumerable mentions = null, CancellationToken cancellationToken = default); + Task SendMessageToContactAsync(string userId, string recipientEmail, string message, string replyMessageId = null, IEnumerable fileIds = null, IEnumerable mentions = null, CancellationToken cancellationToken = default); /// /// Send a message to a channel of which the sender is a member. @@ -186,12 +188,14 @@ public interface IChat /// The unique identifier of the sender. /// The channel Id. /// The message. + /// The reply message's ID. + /// A list of the file IDs to send. This field only accepts a maximum of six file IDs. /// Mentions. /// The cancellation token. /// /// The message Id. /// - Task SendMessageToChannelAsync(string userId, string channelId, string message, IEnumerable mentions = null, CancellationToken cancellationToken = default); + Task SendMessageToChannelAsync(string userId, string channelId, string message, string replyMessageId = null, IEnumerable fileIds = null, IEnumerable mentions = null, CancellationToken cancellationToken = default); /// /// Retrieve the chat messages sent/received to/from a contact. From 96ef7423b5af8a11f7d3a0a2191c78f977cb42d9 Mon Sep 17 00:00:00 2001 From: Jericho Date: Wed, 8 Jun 2022 09:47:33 -0400 Subject: [PATCH 12/32] Do not treat "Obsolete" as an error --- Source/ZoomNet/ZoomNet.csproj | 1 + 1 file changed, 1 insertion(+) diff --git a/Source/ZoomNet/ZoomNet.csproj b/Source/ZoomNet/ZoomNet.csproj index 4e3c24e4..495fccec 100644 --- a/Source/ZoomNet/ZoomNet.csproj +++ b/Source/ZoomNet/ZoomNet.csproj @@ -58,6 +58,7 @@ true + 612,618 From c629b36ded0247bc9c12c9c009a78663f8e22017 Mon Sep 17 00:00:00 2001 From: Jericho Date: Wed, 8 Jun 2022 10:24:33 -0400 Subject: [PATCH 13/32] Change the order of the webhook events in EventType to match Zoom's documentation --- Source/ZoomNet/Models/Webhooks/EventType.cs | 198 +++++++++++--------- 1 file changed, 105 insertions(+), 93 deletions(-) diff --git a/Source/ZoomNet/Models/Webhooks/EventType.cs b/Source/ZoomNet/Models/Webhooks/EventType.cs index b1e463b4..70bba9b6 100644 --- a/Source/ZoomNet/Models/Webhooks/EventType.cs +++ b/Source/ZoomNet/Models/Webhooks/EventType.cs @@ -37,154 +37,160 @@ public enum EventType MeetingDeleted, /// - /// A meeting has been updated. + /// A meeting has ended. /// - [EnumMember(Value = "meeting.updated")] - MeetingUpdated, + [EnumMember(Value = "meeting.ended")] + MeetingEnded, /// - /// A meeting has been permanently deleted. + /// A meeting live stream has started. /// - [EnumMember(Value = "meeting.permanently_deleted")] - MeetingPermanentlyDeleted, + [EnumMember(Value = "meeting.live_streaming_started")] + MeetingLiveStreamStarted, /// - /// A meeting has started. + /// A meeting live stream has stopped. /// - [EnumMember(Value = "meeting.started")] - MeetingStarted, + [EnumMember(Value = "meeting.live_streaming_stopped")] + MeetingLiveStreamStopped, /// - /// A meeting has ended. + /// A meeting host has admitted a participant from a waiting room to the meeting. /// - [EnumMember(Value = "meeting.ended")] - MeetingEnded, + [EnumMember(Value = "meeting.participant_admitted")] + MeetingParticipantAdmitted, /// - /// A meeting has been recovered. + /// An attendee completed an end-of-meeting experience feedback survey for a meeting. /// - [EnumMember(Value = "meeting.recovered")] - MeetingRecovered, + [EnumMember(Value = "meeting.participant_feedback")] + MeetingParticipantFeedback, /// - /// A participant has registered for a meeting. + /// An attendee has joined the meeting before the host. /// - [EnumMember(Value = "meeting.registration_created")] - MeetingRegistrationCreated, + [EnumMember(Value = "meeting.participant_jbh_joined")] + MeetingParticipantJoinedBeforeHost, /// - /// A meeting registration has been approved. + /// An attendee is wating for the host to join the meeting. /// - [EnumMember(Value = "meeting.registration_approved")] - MeetingRegistrationApproved, + [EnumMember(Value = "meeting.participant_jbh_waiting")] + MeetingParticipantWaitingForHost, /// - /// A meeting registration has been cancelled. + /// An attendee has joined a meting waiting room. /// - [EnumMember(Value = "meeting.registration_cancelled")] - MeetingRegistrationCancelled, + [EnumMember(Value = "meeting.participant_joined_waiting_room")] + MeetingParticipantJoinedWaitingRoom, /// - /// A meeting registration has been denied. + /// A meeting host has admitted a participant from a waiting room to the meeting. /// - [EnumMember(Value = "meeting.registration_denied")] - MeetingRegistrationDenied, + [EnumMember(Value = "meeting.participant_joined")] + MeetingParticipantJoined, /// - /// An attendee or the host has started sharing their screen during a meeting. + /// An attendee has left a meting waiting room. /// - [EnumMember(Value = "meeting.sharing_started")] - MeetingSharingStarted, + [EnumMember(Value = "meeting.participant_left_waiting_room")] + MeetingParticipantLeftWaitingRoom, /// - /// An attendee or the host has stoped sharing their screen during a meeting. + /// A meeting participant has left the meeting. /// - [EnumMember(Value = "meeting.sharing_ended")] - MeetingSharingEnded, + [EnumMember(Value = "meeting.participant_left")] + MeetingParticipantLeft, /// - /// An attendee is wating for the host to join the meeting. + /// A meeting participant who has already joined a meeting is sent back to the waiting room during the meeting. /// - [EnumMember(Value = "meeting.participant_jbh_waiting")] - MeetingParticipantWaitingForHost, + [EnumMember(Value = "meeting.participant_put_in_waiting_room")] + MeetingParticipantSentToWaitingRoom, /// - /// An attendee has joined the meeting before the host. + /// A host or meeting attendee changed their role during the meeting. /// - [EnumMember(Value = "meeting.participant_jbh_joined")] - MeetingParticipantJoinedBeforeHost, + [EnumMember(Value = "meeting.participant_role_changed")] + MeetingParticipantRolechanged, /// - /// An attendee has joined a meting waiting room. + /// A meeting has been permanently deleted. /// - [EnumMember(Value = "meeting.participant_joined_waiting_room")] - MeetingParticipantJoinedWaitingRoom, + [EnumMember(Value = "meeting.permanently_deleted")] + MeetingPermanentlyDeleted, /// - /// An attendee has left a meting waiting room. + /// A meeting has been recovered. /// - [EnumMember(Value = "meeting.participant_left_waiting_room")] - MeetingParticipantLeftWaitingRoom, + [EnumMember(Value = "meeting.recovered")] + MeetingRecovered, /// - /// A meeting host has admitted a participant from a waiting room to the meeting. + /// A meeting registration has been approved. /// - [EnumMember(Value = "meeting.participant_admitted")] - MeetingParticipantAdmitted, + [EnumMember(Value = "meeting.registration_approved")] + MeetingRegistrationApproved, /// - /// A meeting host has admitted a participant from a waiting room to the meeting. + /// A meeting registration has been cancelled. /// - [EnumMember(Value = "meeting.participant_joined")] - MeetingParticipantJoined, + [EnumMember(Value = "meeting.registration_cancelled")] + MeetingRegistrationCancelled, /// - /// A meeting participant who has already joined a meeting is sent back to the waiting room during the meeting. + /// A participant has registered for a meeting. /// - [EnumMember(Value = "meeting.participant_put_in_waiting_room")] - MeetingParticipantSentToWaitingRoom, + [EnumMember(Value = "meeting.registration_created")] + MeetingRegistrationCreated, /// - /// A meeting participant has left the meeting. + /// A meeting registration has been denied. /// - [EnumMember(Value = "meeting.participant_left")] - MeetingParticipantLeft, + [EnumMember(Value = "meeting.registration_denied")] + MeetingRegistrationDenied, /// - /// A meeting live stream has started. + /// An attendee or the host has stoped sharing their screen during a meeting. /// - [EnumMember(Value = "meeting.live_streaming_started")] - MeetingLiveStreamStarted, + [EnumMember(Value = "meeting.sharing_ended")] + MeetingSharingEnded, /// - /// A meeting live stream has stoipped. + /// An attendee or the host has started sharing their screen during a meeting. /// - [EnumMember(Value = "meeting.live_streaming_stopped")] - MeetingLiveStreamStopped, + [EnumMember(Value = "meeting.sharing_started")] + MeetingSharingStarted, /// - /// A webinar has been created. + /// A meeting has started. /// - [EnumMember(Value = "webinar.created")] - WebinarCreated, + [EnumMember(Value = "meeting.started")] + MeetingStarted, /// - /// A webinar has been deleted. + /// A meeting has been updated. /// - [EnumMember(Value = "webinar.deleted")] - WebinarDeleted, + [EnumMember(Value = "meeting.updated")] + MeetingUpdated, /// - /// A webinar has been updated. + /// A service issue has been encountered during a webinar. /// - [EnumMember(Value = "webinar.updated")] - WebinarUpdated, + [EnumMember(Value = "webinar.alerts")] + WebinarServiceIssue, /// - /// A webinar has started. + /// A webinar has been created. /// - [EnumMember(Value = "webinar.started")] - WebinarStarted, + [EnumMember(Value = "webinar.created")] + WebinarCreated, + + /// + /// A webinar has been deleted. + /// + [EnumMember(Value = "webinar.deleted")] + WebinarDeleted, /// /// A webinar has ended. @@ -193,16 +199,16 @@ public enum EventType WebinarEnded, /// - /// A service issue has been encountered during a webinar. + /// A webinar host or participant joined a webinar. /// - [EnumMember(Value = "webinar.alerts")] - WebinarServiceIssue, + [EnumMember(Value = "webinar.participant_joined")] + WebinarParticipantJoined, /// - /// A participant has registered for a webinar. + /// A webinar host or participant left a webinar. /// - [EnumMember(Value = "webinar.registration_created")] - WebinarRegistrationCreated, + [EnumMember(Value = "webinar.participant_left")] + WebinarParticipantLeft, /// /// A webinar registration has been approved. @@ -217,16 +223,16 @@ public enum EventType WebinarRegistrationCancelled, /// - /// A webinar registration has been denied. + /// A participant has registered for a webinar. /// - [EnumMember(Value = "webinar.registration_denied")] - WebinarRegistrationDenied, + [EnumMember(Value = "webinar.registration_created")] + WebinarRegistrationCreated, /// - /// An app user or account user has started sharing their screen during a webinar. + /// A webinar registration has been denied. /// - [EnumMember(Value = "webinar.sharing_started")] - WebinarSharingStarted, + [EnumMember(Value = "webinar.registration_denied")] + WebinarRegistrationDenied, /// /// An app user or account user has stopped sharing their screen during a webinar. @@ -235,15 +241,21 @@ public enum EventType WebinarSharingEnded, /// - /// A webinar host or participant joined a webinar. + /// An app user or account user has started sharing their screen during a webinar. /// - [EnumMember(Value = "webinar.participant_joined")] - WebinarParticipantJoined, + [EnumMember(Value = "webinar.sharing_started")] + WebinarSharingStarted, /// - /// A webinar host or participant left a webinar. + /// A webinar has started. /// - [EnumMember(Value = "webinar.participant_left")] - WebinarParticipantLeft, + [EnumMember(Value = "webinar.started")] + WebinarStarted, + + /// + /// A webinar has been updated. + /// + [EnumMember(Value = "webinar.updated")] + WebinarUpdated, } } From f41ecb45e41f657e7e1dd10897868b8d3dacaad8 Mon Sep 17 00:00:00 2001 From: Jericho Date: Wed, 8 Jun 2022 10:34:45 -0400 Subject: [PATCH 14/32] Add HostCanSaveVideoOrder property to the MeetingSettings model Resolves #219 --- Source/ZoomNet/Models/MeetingSettings.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Source/ZoomNet/Models/MeetingSettings.cs b/Source/ZoomNet/Models/MeetingSettings.cs index 32f0bafb..f4070f77 100644 --- a/Source/ZoomNet/Models/MeetingSettings.cs +++ b/Source/ZoomNet/Models/MeetingSettings.cs @@ -138,5 +138,11 @@ public class MeetingSettings /// [JsonPropertyName("request_permission_to_unmute_participants")] public bool? RequestPermissionToUnmutePartecipants { get; set; } + + /// + /// Gets or sets the value indicating whether the 'Allow host to save video order' feature is enabled. + /// + [JsonPropertyName("host_save_video_order")] + public bool? HostCanSaveVideoOrder { get; set; } } } From bae7f94ef5ae165706e53fa040844a06f13404ee Mon Sep 17 00:00:00 2001 From: Jericho Date: Wed, 8 Jun 2022 10:47:29 -0400 Subject: [PATCH 15/32] Change the order of the MeetingSettings properties to match Zoom's documentation --- Source/ZoomNet/Models/MeetingSettings.cs | 143 ++++++++++++----------- 1 file changed, 74 insertions(+), 69 deletions(-) diff --git a/Source/ZoomNet/Models/MeetingSettings.cs b/Source/ZoomNet/Models/MeetingSettings.cs index f4070f77..7f099482 100644 --- a/Source/ZoomNet/Models/MeetingSettings.cs +++ b/Source/ZoomNet/Models/MeetingSettings.cs @@ -1,3 +1,4 @@ +using System; using System.Text.Json.Serialization; namespace ZoomNet.Models @@ -8,141 +9,145 @@ namespace ZoomNet.Models public class MeetingSettings { /// - /// Gets or sets the value indicating whether to start video when host joins the meeting. + /// Gets or sets the value indicating alternative hosts email addresses or IDs. Multiple value separated by semicolon. /// - [JsonPropertyName("host_video")] - public bool? StartVideoWhenHostJoins { get; set; } + [JsonPropertyName("alternative_hosts")] + public string AlternativeHosts { get; set; } /// - /// Gets or sets the value indicating whether to start video when participants join the meeting. + /// Gets or sets the approval type. /// - [JsonPropertyName("participant_video")] - public bool? StartVideoWhenParticipantsJoin { get; set; } + [JsonPropertyName("approval_type")] + public ApprovalType? ApprovalType { get; set; } /// - /// Gets or sets the value indicating whether the meeting should be hosted in China. + /// Gets or sets the value indicating how participants can join the audio portion of the meeting. /// - [JsonPropertyName("cn_meeting")] - public bool? HostInChina { get; set; } + [JsonPropertyName("audio")] + public AudioType? Audio { get; set; } /// - /// Gets or sets the value indicating whether the meeting should be hosted in India. + /// Gets or sets the value indicating if audio is recorded and if so, when the audio is saved. /// - [JsonPropertyName("in_meeting")] - public bool? HostInIndia { get; set; } + [JsonPropertyName("auto_recording")] + public RecordingType? AutoRecording { get; set; } /// - /// Gets or sets the value indicating whether participants can join the meeting before the host starts the meeting. Only used for scheduled or recurring meetings. + /// Gets or sets the value indicating whether registration is closed after event date. /// - [JsonPropertyName("join_before_host")] - public bool? JoinBeforeHost { get; set; } + [JsonPropertyName("close_registration")] + public bool? CloseRegistration { get; set; } /// - /// Gets or sets the value indicating whether participants are muted upon entry. + /// Gets or sets the value indicating whether the meeting should be hosted in China. /// - [JsonPropertyName("mute_upon_entry")] - public bool? MuteUponEntry { get; set; } + [JsonPropertyName("cn_meeting")] + [Obsolete("Deprecated")] + public bool? HostInChina { get; set; } /// - /// Gets or sets the value indicating whether a watermark should be displayed when viewing shared screen. + /// Gets or sets the contact email for registration. /// - [JsonPropertyName("watermark")] - public bool? Watermark { get; set; } + [JsonPropertyName("contact_email")] + public string ContactEmail { get; set; } /// - /// Gets or sets the value indicating whether to use Personal Meeting ID. Only used for scheduled meetings and recurring meetings with no fixed time. + /// Gets or sets the contact name for registration. /// - [JsonPropertyName("use_pmi")] - public bool? UsePmi { get; set; } + [JsonPropertyName("contact_name")] + public string ContactName { get; set; } /// - /// Gets or sets the approval type. + /// Gets or sets the value indicating that only signed-in users can join this meeting. /// - [JsonPropertyName("approval_type")] - public ApprovalType? ApprovalType { get; set; } + [JsonPropertyName("enforce_login")] + [Obsolete("This field is deprecated and will not be supported in the future.")] + public bool? EnforceLogin { get; set; } /// - /// Gets or sets the registration type. Used for recurring meeting with fixed time only. + /// Gets or sets the value indicating only signed-in users with specified domains can join this meeting. /// - [JsonPropertyName("registration_type")] - public RegistrationType? RegistrationType { get; set; } + [JsonPropertyName("enforce_login_domains")] + [Obsolete("This field is deprecated and will not be supported in the future.")] + public string EnforceLoginDomains { get; set; } /// - /// Gets or sets the value indicating how participants can join the audio portion of the meeting. + /// Gets or sets the list of global dial-in countries. /// - [JsonPropertyName("audio")] - public AudioType? Audio { get; set; } + [JsonPropertyName("global_dial_in_countries")] + public string[] GlobalDialInCountries { get; set; } /// - /// Gets or sets the value indicating if audio is recorded and if so, when the audio is saved. + /// Gets or sets the value indicating whether the 'Allow host to save video order' feature is enabled. /// - [JsonPropertyName("auto_recording")] - public RecordingType? AutoRecording { get; set; } + [JsonPropertyName("host_save_video_order")] + public bool? HostCanSaveVideoOrder { get; set; } /// - /// Gets or sets the value indicating that only signed-in users can join this meeting. + /// Gets or sets the value indicating whether to start video when host joins the meeting. /// - [JsonPropertyName("enforce_login")] - public bool? EnforceLogin { get; set; } + [JsonPropertyName("host_video")] + public bool? StartVideoWhenHostJoins { get; set; } /// - /// Gets or sets the value indicating only signed-in users with specified domains can join this meeting. + /// Gets or sets the value indicating whether the meeting should be hosted in India. /// - [JsonPropertyName("enforce_login_domains")] - public string EnforceLoginDomains { get; set; } + [JsonPropertyName("in_meeting")] + [Obsolete("Deprecated")] + public bool? HostInIndia { get; set; } /// - /// Gets or sets the value indicating alternative hosts email addresses or IDs. Multiple value separated by semicolon. + /// Gets or sets the value indicating whether participants can join the meeting before the host starts the meeting. Only used for scheduled or recurring meetings. /// - [JsonPropertyName("alternative_hosts")] - public string AlternativeHosts { get; set; } + [JsonPropertyName("join_before_host")] + public bool? JoinBeforeHost { get; set; } /// - /// Gets or sets the value indicating whether registration is closed after event date. + /// Gets or sets the value indicating whether participants are muted upon entry. /// - [JsonPropertyName("close_registration")] - public bool? CloseRegistration { get; set; } + [JsonPropertyName("mute_upon_entry")] + public bool? MuteUponEntry { get; set; } /// - /// Gets or sets the value indicating whether a confirmation email is sent when a participant registers. + /// Gets or sets the value indicating whether to start video when participants join the meeting. /// - [JsonPropertyName("registrants_confirmation_email")] - public bool? SendRegistrationConfirmationEmail { get; set; } + [JsonPropertyName("participant_video")] + public bool? StartVideoWhenParticipantsJoin { get; set; } /// - /// Gets or sets the value indicating whether to use a waiting room. + /// Gets or sets the value indicating whether a confirmation email is sent when a participant registers. /// - [JsonPropertyName("waiting_room")] - public bool? WaitingRoom { get; set; } + [JsonPropertyName("registrants_confirmation_email")] + public bool? SendRegistrationConfirmationEmail { get; set; } /// - /// Gets or sets the list of global dial-in countries. + /// Gets or sets the registration type. Used for recurring meeting with fixed time only. /// - [JsonPropertyName("global_dial_in_countries")] - public string[] GlobalDialInCountries { get; set; } + [JsonPropertyName("registration_type")] + public RegistrationType? RegistrationType { get; set; } /// - /// Gets or sets the contact name for registration. + /// Gets or sets the value indicating whether to ask the permission to unmute partecipants. /// - [JsonPropertyName("contact_name")] - public string ContactName { get; set; } + [JsonPropertyName("request_permission_to_unmute_participants")] + public bool? RequestPermissionToUnmutePartecipants { get; set; } /// - /// Gets or sets the contact email for registration. + /// Gets or sets the value indicating whether to use Personal Meeting ID. Only used for scheduled meetings and recurring meetings with no fixed time. /// - [JsonPropertyName("contact_email")] - public string ContactEmail { get; set; } + [JsonPropertyName("use_pmi")] + public bool? UsePmi { get; set; } /// - /// Gets or sets the value indicating whether to ask the permission to unmute partecipants. + /// Gets or sets the value indicating whether to use a waiting room. /// - [JsonPropertyName("request_permission_to_unmute_participants")] - public bool? RequestPermissionToUnmutePartecipants { get; set; } + [JsonPropertyName("waiting_room")] + public bool? WaitingRoom { get; set; } /// - /// Gets or sets the value indicating whether the 'Allow host to save video order' feature is enabled. + /// Gets or sets the value indicating whether a watermark should be displayed when viewing shared screen. /// - [JsonPropertyName("host_save_video_order")] - public bool? HostCanSaveVideoOrder { get; set; } + [JsonPropertyName("watermark")] + public bool? Watermark { get; set; } } } From 545c05c6910523e44b18f2924c13728b36c34a5d Mon Sep 17 00:00:00 2001 From: Jericho Date: Sat, 11 Jun 2022 14:41:24 -0400 Subject: [PATCH 16/32] Fix xml comments --- Source/ZoomNet/Resources/IContacts.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Source/ZoomNet/Resources/IContacts.cs b/Source/ZoomNet/Resources/IContacts.cs index 46512e50..91053097 100644 --- a/Source/ZoomNet/Resources/IContacts.cs +++ b/Source/ZoomNet/Resources/IContacts.cs @@ -7,7 +7,6 @@ namespace ZoomNet.Resources /// /// Allows you to manage contacts. /// - /// /// /// See Zoom documentation for more information. /// @@ -28,7 +27,7 @@ public interface IContacts /// /// Search contacts. /// - /// The search keyword: either first name, last na,me or email of the contact. + /// The search keyword: either first name, last name or email of the contact. /// Indicate whether you want the status of a contact to be included in the response. /// The number of records returned within a single API call. /// The paging token. From c8e5ade6cea4d1163ec57136c921ff8057a29f8a Mon Sep 17 00:00:00 2001 From: Jericho Date: Sat, 11 Jun 2022 16:52:39 -0400 Subject: [PATCH 17/32] Upgrade nuget package references --- .../ZoomNet.IntegrationTests.csproj | 2 +- Source/ZoomNet.UnitTests/ZoomNet.UnitTests.csproj | 6 +++--- Source/ZoomNet/ZoomNet.csproj | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Source/ZoomNet.IntegrationTests/ZoomNet.IntegrationTests.csproj b/Source/ZoomNet.IntegrationTests/ZoomNet.IntegrationTests.csproj index e4a7be46..422e778b 100644 --- a/Source/ZoomNet.IntegrationTests/ZoomNet.IntegrationTests.csproj +++ b/Source/ZoomNet.IntegrationTests/ZoomNet.IntegrationTests.csproj @@ -14,7 +14,7 @@ - + diff --git a/Source/ZoomNet.UnitTests/ZoomNet.UnitTests.csproj b/Source/ZoomNet.UnitTests/ZoomNet.UnitTests.csproj index a1cb3567..24a0d403 100644 --- a/Source/ZoomNet.UnitTests/ZoomNet.UnitTests.csproj +++ b/Source/ZoomNet.UnitTests/ZoomNet.UnitTests.csproj @@ -12,12 +12,12 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + - + all runtime; build; native; contentfiles; analyzers diff --git a/Source/ZoomNet/ZoomNet.csproj b/Source/ZoomNet/ZoomNet.csproj index 495fccec..4f989b34 100644 --- a/Source/ZoomNet/ZoomNet.csproj +++ b/Source/ZoomNet/ZoomNet.csproj @@ -36,13 +36,13 @@ - + - + From f52dfd8501c7d9fbf506f5b440ba73d15471bbe1 Mon Sep 17 00:00:00 2001 From: Jericho Date: Mon, 27 Jun 2022 20:06:32 -0400 Subject: [PATCH 18/32] DayOfWeekConverter must read and write the raw value as integer rather than a string Resolves #224 --- Source/ZoomNet/Json/DayOfWeekConverter.cs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/Source/ZoomNet/Json/DayOfWeekConverter.cs b/Source/ZoomNet/Json/DayOfWeekConverter.cs index 509535a8..fec89404 100644 --- a/Source/ZoomNet/Json/DayOfWeekConverter.cs +++ b/Source/ZoomNet/Json/DayOfWeekConverter.cs @@ -19,8 +19,7 @@ internal class DayOfWeekConverter : JsonConverter if (reader.TokenType == JsonTokenType.Null) return null; - var rawValue = reader.GetString(); - var value = Convert.ToInt32(rawValue) - 1; + var value = reader.GetInt32() - 1; return (DayOfWeek)value; } @@ -37,8 +36,8 @@ public override void Write(Utf8JsonWriter writer, DayOfWeek? value, JsonSerializ } else { - var singleDay = (Convert.ToInt32(value.Value) + 1).ToString(); - writer.WriteStringValue(singleDay); + var singleDay = Convert.ToInt32(value.Value) + 1; + writer.WriteNumberValue(singleDay); } } } From 1ce47bd92d144e8fc61ce83b00838f0d0fb30733 Mon Sep 17 00:00:00 2001 From: Jericho Date: Fri, 24 Jun 2022 10:34:06 -0400 Subject: [PATCH 19/32] Upgrade to .NET SDK 6.0.301 (cherry picked from commit 02d278d2d9246a765cdc1ffc5cd3d4f54256189e) --- global.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/global.json b/global.json index 20eb15d6..3d5658fe 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "6.0.202", + "version": "6.0.301", "rollForward": "latestFeature" } } \ No newline at end of file From f0c76f33a70c8a2f18f3fac6b8a3bad0a1996e2d Mon Sep 17 00:00:00 2001 From: Jericho Date: Fri, 24 Jun 2022 09:58:12 -0400 Subject: [PATCH 20/32] Update README.md to provide code sample for the Server-to-Server OAuth scenario --- README.md | 66 ++++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 60 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 4f4f16f2..e054b8f7 100644 --- a/README.md +++ b/README.md @@ -56,16 +56,19 @@ var apiSecret = "... your API secret ..."; var connectionInfo = new JwtConnectionInfo(apiKey, apiSecret); ``` +> **Warning:** Zoom has announced that this authentication method would be obsolete in June 2023. The recommendation is to swith to Server-to-Server OAuth. + + #### Connection using OAuth Using OAuth is much more complicated than using JWT but at the same time, it is more flexible because you can define which permissions your app requires. When a user installs your app, they are presented with the list of permissions your app requires and they are given the opportunity to accept. The Zoom documentation has a document about [how to create an OAuth app](https://marketplace.zoom.us/docs/guides/build/oauth-app) and another document about the [OAuth autorization flow](https://marketplace.zoom.us/docs/guides/auth/oauth) but I personnality was very confused by the later document so here is a brief step-by-step summary: - you create an OAuth app, define which permissions your app requires and publish the app in the Zoom marketplace. - user installs your app. During installation, user is presentd with a screen listing the permissons your app requires. User must click `accept`. -- Zoom generates a "authorization code". This code can be used only once to generate the first access token and refresh token. I CAN'T STRESS THIS ENOUGH: the authorization code can be used only one time. This was the confusing part to me: somehow I didn't understand that this code could be used only one time and I was attempting to use it repeatedly. Zoom would accept the code one time and would reject it subsequently, which lead to many hours of frustration while trying to figure out why the code was sometimes rejected. +- Zoom generates a "authorization code". This code can be used only once to generate the first access token and refresh token. I CAN'T STRESS THIS ENOUGH: the authorization code can be used only one time. This was the confusing part to me: somehow I didn't understand that this code could be used only one time and I was attempting to use it repeatedly. Zoom would accept the code the first time and would reject it subsequently, which lead to many hours of frustration while trying to figure out why the code was sometimes rejected. - The access token is valid for 60 minutes and must therefore be "refreshed" periodically. -ZoomNet takes care of generating the access token and refresh token but it's your responsability to store these generated values. +ZoomNet takes care of generating the access token and the refresh token but it's your responsability to store these generated values. ```csharp var clientId = "... your client ID ..."; @@ -73,16 +76,67 @@ var clientSecret = "... your client secret ..."; var refreshToken = "... the refresh token previously issued by Zoom ..."; var accessToken = "... the access token previously issued by Zoom ..."; var connectionInfo = new OAuthConnectionInfo(clientId, clientSecret, refreshToken, accessToken, - (newRefreshToken, newAccessToken) => - { - /* + (newRefreshToken, newAccessToken) => + { + /* Save the new refresh token and the access token to a safe place so you can provide it the next time - you need to instantiate an OAuthConnectionInfo + you need to instantiate an OAuthConnectionInfo. + */ + }); +``` + +For demonstration purposes, here's how you could use your operating system's environment variables to store the tokens + +```csharp +var clientId = "... your client ID ..."; +var clientSecret = "... your client secret ..."; +var refreshToken = Environment.GetEnvironmentVariable("ZOOM_OAUTH_REFRESHTOKEN", EnvironmentVariableTarget.User); +var accessToken = Environment.GetEnvironmentVariable("ZOOM_OAUTH_ACCESSTOKEN", EnvironmentVariableTarget.User); +var connectionInfo = new OAuthConnectionInfo(clientId, clientSecret, refreshToken, accessToken, + (newRefreshToken, newAccessToken) => + { + Environment.SetEnvironmentVariable("ZOOM_OAUTH_REFRESHTOKEN", newRefreshToken, EnvironmentVariableTarget.User); + Environment.SetEnvironmentVariable("ZOOM_OAUTH_ACCESSTOKEN", newAccessToken, EnvironmentVariableTarget.User); + }); +``` + +#### Connection using Server-to-Server OAuth + +This authentication method is the replacement for JWT authentication which Zoom announced will be made obsolete in June 2023. + +From Zoom's documentation: +> A Server-to-Server OAuth app enables you to securely integrate with Zoom APIs and get your account owner access token without user interaction. This is different from the OAuth app type, which requires user authentication. See Using OAuth 2.0 for details. + +ZoomNet takes care of getting a new access token and it also refreshes a previously issued token when it expires (Server-to-Server access token are valid for one hour). +Therefore + +```csharp +var clientId = "... your client ID ..."; +var clientSecret = "... your client secret ..."; +var accountId = "... your account id ..."; +var connectionInfo = new OAuthConnectionInfo(clientId, clientSecret, accountId, + (_, newAccessToken) => + { + /* + Server-to-Server OAuth does not use a refresh token. That's why I used '_' as the first parameter + in this delegate declaration. Furthermore, ZoomNet will take care of getting a new access token + and to refresh it whenever it expires therefore there is no need for you to preserve the token + like you must do for the 'standard' OAuth authentication. + + In fact, this delegate is completely optional when using Server-to-Server OAuth. Feel free to pass + a null value in lieu of a delegate. */ }); ``` +The delegate being optional in the server-to-server scenario you can therefore simplify the connection info declaration like so: + +```csharp +var connectionInfo = new OAuthConnectionInfo(clientId, clientSecret, accountId, null); +``` + + ### Client You declare your client variable like so: From 64f9a3f38e069157ea830c775f1d265fe04891c5 Mon Sep 17 00:00:00 2001 From: Jericho Date: Fri, 24 Jun 2022 10:09:50 -0400 Subject: [PATCH 21/32] Handle Server-to-Server OAuth --- Source/ZoomNet/Models/OAuthGrantType.cs | 6 ++++ Source/ZoomNet/OAuthConnectionInfo.cs | 30 ++++++++++++++++++- Source/ZoomNet/Utilities/OAuthTokenHandler.cs | 12 ++++++-- 3 files changed, 44 insertions(+), 4 deletions(-) diff --git a/Source/ZoomNet/Models/OAuthGrantType.cs b/Source/ZoomNet/Models/OAuthGrantType.cs index 1671d2e0..372ae312 100644 --- a/Source/ZoomNet/Models/OAuthGrantType.cs +++ b/Source/ZoomNet/Models/OAuthGrantType.cs @@ -7,6 +7,12 @@ namespace ZoomNet.Models /// public enum OAuthGrantType { + /// + /// Account Credentials. This is the grant type for "Server-to-Server" OAuth. + /// + [EnumMember(Value = "account_credentials")] + AccountCredentials, + /// /// Authorization code. This is the most commonly used grant type for Zoom APIs. /// diff --git a/Source/ZoomNet/OAuthConnectionInfo.cs b/Source/ZoomNet/OAuthConnectionInfo.cs index 3f7a25f4..f49e3b85 100644 --- a/Source/ZoomNet/OAuthConnectionInfo.cs +++ b/Source/ZoomNet/OAuthConnectionInfo.cs @@ -16,6 +16,12 @@ namespace ZoomNet /// public class OAuthConnectionInfo : IConnectionInfo { + /// + /// Gets the account id. + /// + /// This is relevant only when using Server-to-Server authentication. + public string AccountId { get; } + /// /// Gets the client id. /// @@ -100,7 +106,9 @@ public OAuthConnectionInfo(string clientId, string clientSecret) /// always necessary. It seems that some developers get a "REDIRECT URI MISMATCH" exception when /// they omit this value but other developers don't. Therefore, the redirectUri parameter is /// marked as optional in ZoomNet which allows you to specify it or omit it depending on your - /// situation. See this Github issue for more details. + /// situation. See this Github issue + /// and this support thread + /// for more details. /// /// Your Client Id. /// Your Client Secret. @@ -139,5 +147,25 @@ public OAuthConnectionInfo(string clientId, string clientSecret, string refreshT GrantType = OAuthGrantType.RefreshToken; OnTokenRefreshed = onTokenRefreshed; } + + /// + /// Initializes a new instance of the class. + /// + /// + /// Use this constructor when you want to use Server-to-Server OAuth authentication. + /// + /// Your Client Id. + /// Your Client Secret. + /// Your Account Id. + /// The delegate invoked when the token is refreshed. + public OAuthConnectionInfo(string clientId!!, string clientSecret!!, string accountId!!, OnTokenRefreshedDelegate onTokenRefreshed) + { + ClientId = clientId ?? throw new ArgumentNullException(nameof(clientId)); + ClientSecret = clientSecret ?? throw new ArgumentNullException(nameof(clientSecret)); + AccountId = accountId ?? throw new ArgumentNullException(nameof(accountId)); + TokenExpiration = DateTime.MinValue; + GrantType = OAuthGrantType.AccountCredentials; + OnTokenRefreshed = onTokenRefreshed; + } } } diff --git a/Source/ZoomNet/Utilities/OAuthTokenHandler.cs b/Source/ZoomNet/Utilities/OAuthTokenHandler.cs index c9769d45..c402dc3d 100644 --- a/Source/ZoomNet/Utilities/OAuthTokenHandler.cs +++ b/Source/ZoomNet/Utilities/OAuthTokenHandler.cs @@ -6,7 +6,6 @@ using System.Linq; using System.Net.Http; using System.Net.Http.Headers; -using System.Runtime.Serialization; using System.Text; using System.Text.Json; using System.Threading; @@ -15,7 +14,7 @@ namespace ZoomNet.Utilities { /// - /// Handler to ensure requests to the Zoom API include a valid JWT token. + /// Handler to ensure requests to the Zoom API include a valid OAuth token. /// /// internal class OAuthTokenHandler : IHttpFilter, ITokenHandler @@ -79,6 +78,9 @@ public string RefreshTokenIfNecessary(bool forceRefresh) var requestUrl = $"https://api.zoom.us/oauth/token?grant_type={grantType}"; switch (_connectionInfo.GrantType) { + case OAuthGrantType.AccountCredentials: + requestUrl += $"&account_id={_connectionInfo.AccountId}"; + break; case OAuthGrantType.AuthorizationCode: requestUrl += $"&code={_connectionInfo.AuthorizationCode}"; if (!string.IsNullOrEmpty(_connectionInfo.RedirectUri)) requestUrl += $"&redirect_uri={_connectionInfo.RedirectUri}"; @@ -106,7 +108,6 @@ public string RefreshTokenIfNecessary(bool forceRefresh) _connectionInfo.RefreshToken = jsonResponse.GetPropertyValue("refresh_token", string.Empty); _connectionInfo.AccessToken = jsonResponse.GetPropertyValue("access_token", string.Empty); - _connectionInfo.GrantType = OAuthGrantType.RefreshToken; _connectionInfo.TokenExpiration = requestTime.AddSeconds(jsonResponse.GetPropertyValue("expires_in", 60 * 60)); _connectionInfo.TokenScope = new ReadOnlyDictionary( jsonResponse.GetPropertyValue("scope", string.Empty) @@ -117,6 +118,11 @@ public string RefreshTokenIfNecessary(bool forceRefresh) .ToDictionary( x => x.Key, x => x.SelectMany(c => c.Value).ToArray())); + + // Please note that Server-to-Server OAuth does not use the refresh token. + // Therefore change the grant type to 'RefreshToken' only when the response includes a refresh token. + if (!string.IsNullOrEmpty(_connectionInfo.RefreshToken)) _connectionInfo.GrantType = OAuthGrantType.RefreshToken; + _connectionInfo.OnTokenRefreshed(_connectionInfo.RefreshToken, _connectionInfo.AccessToken); } finally From f0f04843bf5ed07d60031d2bfbbafdc565a16348 Mon Sep 17 00:00:00 2001 From: Jericho Date: Fri, 24 Jun 2022 10:16:07 -0400 Subject: [PATCH 22/32] Fix unit test --- .../ZoomNet.UnitTests/Utilities/OAuthTokenHandlerTests.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Source/ZoomNet.UnitTests/Utilities/OAuthTokenHandlerTests.cs b/Source/ZoomNet.UnitTests/Utilities/OAuthTokenHandlerTests.cs index 44769b59..40be9691 100644 --- a/Source/ZoomNet.UnitTests/Utilities/OAuthTokenHandlerTests.cs +++ b/Source/ZoomNet.UnitTests/Utilities/OAuthTokenHandlerTests.cs @@ -25,7 +25,12 @@ public void Attempt_to_refresh_token_multiple_times_despite_exception() var clientId = "abc123"; var clientSecret = "xyz789"; var authorizationCode = "INVALID_AUTH_CODE"; - var connectionInfo = new OAuthConnectionInfo(clientId, clientSecret, authorizationCode, null); + var connectionInfo = new OAuthConnectionInfo(clientId, clientSecret, authorizationCode, + (newRefreshToken, newAccessToken) => + { + // Intentionally left blank + }, + null); var apiResponse = "{ \"reason\":\"Invalid authorization code " + authorizationCode + "\",\"error\":\"invalid_request\"}"; var mockHttp = new MockHttpMessageHandler(); From 37bbf387367dd7c2c66d7f18971cbf750b86e132 Mon Sep 17 00:00:00 2001 From: Jericho Date: Fri, 24 Jun 2022 10:20:10 -0400 Subject: [PATCH 23/32] Enable Server-to-Server in integration tests --- .../ZoomNet.IntegrationTests/TestsRunner.cs | 45 ++++++++++++------- 1 file changed, 30 insertions(+), 15 deletions(-) diff --git a/Source/ZoomNet.IntegrationTests/TestsRunner.cs b/Source/ZoomNet.IntegrationTests/TestsRunner.cs index f2bf79c6..b14f9a0f 100644 --- a/Source/ZoomNet.IntegrationTests/TestsRunner.cs +++ b/Source/ZoomNet.IntegrationTests/TestsRunner.cs @@ -58,23 +58,38 @@ public async Task RunAsync() { var clientId = Environment.GetEnvironmentVariable("ZOOM_OAUTH_CLIENTID", EnvironmentVariableTarget.User); var clientSecret = Environment.GetEnvironmentVariable("ZOOM_OAUTH_CLIENTSECRET", EnvironmentVariableTarget.User); - + var accountId = Environment.GetEnvironmentVariable("ZOOM_OAUTH_ACCOUNTID", EnvironmentVariableTarget.User); var refreshToken = Environment.GetEnvironmentVariable("ZOOM_OAUTH_REFRESHTOKEN", EnvironmentVariableTarget.User); var accessToken = Environment.GetEnvironmentVariable("ZOOM_OAUTH_ACCESSTOKEN", EnvironmentVariableTarget.User); - connectionInfo = new OAuthConnectionInfo(clientId, clientSecret, refreshToken, accessToken, - (newRefreshToken, newAccessToken) => - { - Environment.SetEnvironmentVariable("ZOOM_OAUTH_REFRESHTOKEN", newRefreshToken, EnvironmentVariableTarget.User); - Environment.SetEnvironmentVariable("ZOOM_OAUTH_ACCESSTOKEN", newAccessToken, EnvironmentVariableTarget.User); - }); - - //var authorizationCode = "<-- the code generated by Zoom when the app is authorized by the user -->"; - //connectionInfo = new OAuthConnectionInfo(clientId, clientSecret, authorizationCode, - // (newRefreshToken, newAccessToken) => - // { - // Environment.SetEnvironmentVariable("ZOOM_OAUTH_REFRESHTOKEN", newRefreshToken, EnvironmentVariableTarget.User); - // Environment.SetEnvironmentVariable("ZOOM_OAUTH_ACCESSTOKEN", newAccessToken, EnvironmentVariableTarget.User); - // }); + + // Server-to-Server OAuth + if (!string.IsNullOrEmpty(accountId)) + { + connectionInfo = new OAuthConnectionInfo(clientId, clientSecret, accountId, + (_, newAccessToken) => + { + Console.Out.WriteLine($"A new access token was issued: {newAccessToken}"); + }); + } + + // Standard OAuth + else + { + connectionInfo = new OAuthConnectionInfo(clientId, clientSecret, refreshToken, accessToken, + (newRefreshToken, newAccessToken) => + { + Environment.SetEnvironmentVariable("ZOOM_OAUTH_REFRESHTOKEN", newRefreshToken, EnvironmentVariableTarget.User); + Environment.SetEnvironmentVariable("ZOOM_OAUTH_ACCESSTOKEN", newAccessToken, EnvironmentVariableTarget.User); + }); + + //var authorizationCode = "<-- the code generated by Zoom when the app is authorized by the user -->"; + //connectionInfo = new OAuthConnectionInfo(clientId, clientSecret, authorizationCode, + // (newRefreshToken, newAccessToken) => + // { + // Environment.SetEnvironmentVariable("ZOOM_OAUTH_REFRESHTOKEN", newRefreshToken, EnvironmentVariableTarget.User); + // Environment.SetEnvironmentVariable("ZOOM_OAUTH_ACCESSTOKEN", newAccessToken, EnvironmentVariableTarget.User); + // }); + } } var proxy = useFiddler ? new WebProxy($"http://localhost:{fiddlerPort}") : null; From 9b6215d65c76310dda59a02f525287f1198b58a3 Mon Sep 17 00:00:00 2001 From: Jericho Date: Fri, 24 Jun 2022 10:24:42 -0400 Subject: [PATCH 24/32] Make local function 'static' --- Source/ZoomNet/Extensions/Internal.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Source/ZoomNet/Extensions/Internal.cs b/Source/ZoomNet/Extensions/Internal.cs index b5117834..ef0227a2 100644 --- a/Source/ZoomNet/Extensions/Internal.cs +++ b/Source/ZoomNet/Extensions/Internal.cs @@ -405,7 +405,7 @@ internal static async Task AsString(this IRequest request, Encoding enco /// Returns the human readable representation of the TimeSpan. internal static string ToDurationString(this TimeSpan timeSpan) { - void AppendFormatIfNecessary(StringBuilder stringBuilder, string timePart, int value) + static void AppendFormatIfNecessary(StringBuilder stringBuilder, string timePart, int value) { if (value <= 0) return; stringBuilder.AppendFormat($" {value} {timePart}{(value > 1 ? "s" : string.Empty)}"); From 48c6ddf068859f242fab166066c754f05eda8dae Mon Sep 17 00:00:00 2001 From: Jericho Date: Fri, 24 Jun 2022 10:26:44 -0400 Subject: [PATCH 25/32] Convert switch statements to expressions --- Source/ZoomNet/Extensions/Internal.cs | 50 +++++++++++++-------------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/Source/ZoomNet/Extensions/Internal.cs b/Source/ZoomNet/Extensions/Internal.cs index ef0227a2..a54ef9c2 100644 --- a/Source/ZoomNet/Extensions/Internal.cs +++ b/Source/ZoomNet/Extensions/Internal.cs @@ -984,12 +984,12 @@ private static T GetPropertyValue(this JsonElement element, string name, T de if (typeOfT.IsEnum) { - switch (property.Value.ValueKind) + return property.Value.ValueKind switch { - case JsonValueKind.String: return (T)Enum.Parse(typeof(T), property.Value.GetString()); - case JsonValueKind.Number: return (T)Enum.ToObject(typeof(T), property.Value.GetInt16()); - default: throw new ArgumentException($"Unable to convert a {property.Value.ValueKind} into a {typeof(T).FullName}", nameof(T)); - } + JsonValueKind.String => (T)Enum.Parse(typeof(T), property.Value.GetString()), + JsonValueKind.Number => (T)Enum.ToObject(typeof(T), property.Value.GetInt16()), + _ => throw new ArgumentException($"Unable to convert a {property.Value.ValueKind} into a {typeof(T).FullName}", nameof(T)), + }; } if (typeOfT.IsGenericType && typeOfT.GetGenericTypeDefinition() == typeof(Nullable<>)) @@ -1026,27 +1026,27 @@ private static T GetElementValue(this JsonElement element) { var typeOfT = typeof(T); - switch (typeOfT) + return typeOfT switch { - case Type boolType when boolType == typeof(bool): return (T)(object)element.GetBoolean(); - case Type strType when strType == typeof(string): return (T)(object)element.GetString(); - case Type bytesType when bytesType == typeof(byte[]): return (T)(object)element.GetBytesFromBase64(); - case Type sbyteType when sbyteType == typeof(sbyte): return (T)(object)element.GetSByte(); - case Type byteType when byteType == typeof(byte): return (T)(object)element.GetByte(); - case Type shortType when shortType == typeof(short): return (T)(object)element.GetInt16(); - case Type ushortType when ushortType == typeof(ushort): return (T)(object)element.GetUInt16(); - case Type intType when intType == typeof(int): return (T)(object)element.GetInt32(); - case Type uintType when uintType == typeof(uint): return (T)(object)element.GetUInt32(); - case Type longType when longType == typeof(long): return (T)(object)element.GetInt64(); - case Type ulongType when ulongType == typeof(ulong): return (T)(object)element.GetUInt64(); - case Type doubleType when doubleType == typeof(double): return (T)(object)element.GetDouble(); - case Type floatType when floatType == typeof(float): return (T)(object)element.GetSingle(); - case Type decimalType when decimalType == typeof(decimal): return (T)(object)element.GetDecimal(); - case Type datetimeType when datetimeType == typeof(DateTime): return (T)(object)element.GetDateTime(); - case Type offsetType when offsetType == typeof(DateTimeOffset): return (T)(object)element.GetDateTimeOffset(); - case Type guidType when guidType == typeof(Guid): return (T)(object)element.GetGuid(); - default: throw new ArgumentException($"Unsable to map {typeof(T).FullName} to a corresponding JSON type", nameof(T)); - } + Type boolType when boolType == typeof(bool) => (T)(object)element.GetBoolean(), + Type strType when strType == typeof(string) => (T)(object)element.GetString(), + Type bytesType when bytesType == typeof(byte[]) => (T)(object)element.GetBytesFromBase64(), + Type sbyteType when sbyteType == typeof(sbyte) => (T)(object)element.GetSByte(), + Type byteType when byteType == typeof(byte) => (T)(object)element.GetByte(), + Type shortType when shortType == typeof(short) => (T)(object)element.GetInt16(), + Type ushortType when ushortType == typeof(ushort) => (T)(object)element.GetUInt16(), + Type intType when intType == typeof(int) => (T)(object)element.GetInt32(), + Type uintType when uintType == typeof(uint) => (T)(object)element.GetUInt32(), + Type longType when longType == typeof(long) => (T)(object)element.GetInt64(), + Type ulongType when ulongType == typeof(ulong) => (T)(object)element.GetUInt64(), + Type doubleType when doubleType == typeof(double) => (T)(object)element.GetDouble(), + Type floatType when floatType == typeof(float) => (T)(object)element.GetSingle(), + Type decimalType when decimalType == typeof(decimal) => (T)(object)element.GetDecimal(), + Type datetimeType when datetimeType == typeof(DateTime) => (T)(object)element.GetDateTime(), + Type offsetType when offsetType == typeof(DateTimeOffset) => (T)(object)element.GetDateTimeOffset(), + Type guidType when guidType == typeof(Guid) => (T)(object)element.GetGuid(), + _ => throw new ArgumentException($"Unsable to map {typeof(T).FullName} to a corresponding JSON type", nameof(T)), + }; } } } From 12a923f895148f7583e8f40d8deef39929697de9 Mon Sep 17 00:00:00 2001 From: Jericho Date: Fri, 24 Jun 2022 10:28:33 -0400 Subject: [PATCH 26/32] GetPropertyValue does not respect provided default value --- Source/ZoomNet/Extensions/Internal.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Source/ZoomNet/Extensions/Internal.cs b/Source/ZoomNet/Extensions/Internal.cs index a54ef9c2..e4e1cbf2 100644 --- a/Source/ZoomNet/Extensions/Internal.cs +++ b/Source/ZoomNet/Extensions/Internal.cs @@ -468,7 +468,7 @@ internal static string EnsureEndsWith(this string value, string suffix) internal static T GetPropertyValue(this JsonElement element, string name, T defaultValue) { - return GetPropertyValue(element, name, default, false); + return GetPropertyValue(element, name, defaultValue, false); } internal static T GetPropertyValue(this JsonElement element, string name) From 84799d0bff03d6ccdbcd9dabcdf2a5598dee94e1 Mon Sep 17 00:00:00 2001 From: Jericho Date: Fri, 24 Jun 2022 10:32:17 -0400 Subject: [PATCH 27/32] Use parameter null checking --- Source/ZoomNet/OAuthConnectionInfo.cs | 32 +++++++++---------- Source/ZoomNet/Utilities/OAuthTokenHandler.cs | 7 ++-- 2 files changed, 19 insertions(+), 20 deletions(-) diff --git a/Source/ZoomNet/OAuthConnectionInfo.cs b/Source/ZoomNet/OAuthConnectionInfo.cs index f49e3b85..da05face 100644 --- a/Source/ZoomNet/OAuthConnectionInfo.cs +++ b/Source/ZoomNet/OAuthConnectionInfo.cs @@ -86,10 +86,10 @@ public class OAuthConnectionInfo : IConnectionInfo /// /// Your Client Id. /// Your Client Secret. - public OAuthConnectionInfo(string clientId, string clientSecret) + public OAuthConnectionInfo(string clientId!!, string clientSecret!!) { - ClientId = clientId ?? throw new ArgumentNullException(nameof(clientId)); - ClientSecret = clientSecret ?? throw new ArgumentNullException(nameof(clientSecret)); + ClientId = clientId; + ClientSecret = clientSecret; GrantType = OAuthGrantType.ClientCredentials; } @@ -115,11 +115,11 @@ public OAuthConnectionInfo(string clientId, string clientSecret) /// The authorization code. /// The delegate invoked when the token is refreshed. /// The Redirect Uri. - public OAuthConnectionInfo(string clientId, string clientSecret, string authorizationCode, OnTokenRefreshedDelegate onTokenRefreshed, string redirectUri = null) + public OAuthConnectionInfo(string clientId!!, string clientSecret!!, string authorizationCode!!, OnTokenRefreshedDelegate onTokenRefreshed!!, string redirectUri = null) { - ClientId = clientId ?? throw new ArgumentNullException(nameof(clientId)); - ClientSecret = clientSecret ?? throw new ArgumentNullException(nameof(clientSecret)); - AuthorizationCode = authorizationCode ?? throw new ArgumentNullException(nameof(authorizationCode)); + ClientId = clientId; + ClientSecret = clientSecret; + AuthorizationCode = authorizationCode; RedirectUri = redirectUri; TokenExpiration = DateTime.MinValue; GrantType = OAuthGrantType.AuthorizationCode; @@ -137,12 +137,12 @@ public OAuthConnectionInfo(string clientId, string clientSecret, string authoriz /// The refresh token. /// The access token. Access tokens expire after 1 hour. ZoomNet will automatically refresh this token when it expires. /// The delegate invoked when the token is refreshed. - public OAuthConnectionInfo(string clientId, string clientSecret, string refreshToken, string accessToken, OnTokenRefreshedDelegate onTokenRefreshed) + public OAuthConnectionInfo(string clientId!!, string clientSecret!!, string refreshToken!!, string accessToken!!, OnTokenRefreshedDelegate onTokenRefreshed!!) { - ClientId = clientId ?? throw new ArgumentNullException(nameof(clientId)); - ClientSecret = clientSecret ?? throw new ArgumentNullException(nameof(clientSecret)); - RefreshToken = refreshToken ?? throw new ArgumentNullException(nameof(refreshToken)); - AccessToken = accessToken ?? throw new ArgumentNullException(nameof(accessToken)); + ClientId = clientId; + ClientSecret = clientSecret; + RefreshToken = refreshToken; + AccessToken = accessToken; TokenExpiration = string.IsNullOrEmpty(accessToken) ? DateTime.MinValue : DateTime.MaxValue; // Set expiration to DateTime.MaxValue when an access token is provided because we don't know when it will expire GrantType = OAuthGrantType.RefreshToken; OnTokenRefreshed = onTokenRefreshed; @@ -157,12 +157,12 @@ public OAuthConnectionInfo(string clientId, string clientSecret, string refreshT /// Your Client Id. /// Your Client Secret. /// Your Account Id. - /// The delegate invoked when the token is refreshed. + /// The delegate invoked when the token is refreshed. In the Server-to-Server scenario, this delegate is optional. public OAuthConnectionInfo(string clientId!!, string clientSecret!!, string accountId!!, OnTokenRefreshedDelegate onTokenRefreshed) { - ClientId = clientId ?? throw new ArgumentNullException(nameof(clientId)); - ClientSecret = clientSecret ?? throw new ArgumentNullException(nameof(clientSecret)); - AccountId = accountId ?? throw new ArgumentNullException(nameof(accountId)); + ClientId = clientId; + ClientSecret = clientSecret; + AccountId = accountId; TokenExpiration = DateTime.MinValue; GrantType = OAuthGrantType.AccountCredentials; OnTokenRefreshed = onTokenRefreshed; diff --git a/Source/ZoomNet/Utilities/OAuthTokenHandler.cs b/Source/ZoomNet/Utilities/OAuthTokenHandler.cs index c402dc3d..b4a6d95a 100644 --- a/Source/ZoomNet/Utilities/OAuthTokenHandler.cs +++ b/Source/ZoomNet/Utilities/OAuthTokenHandler.cs @@ -39,14 +39,13 @@ public IConnectionInfo ConnectionInfo private readonly HttpClient _httpClient; private readonly TimeSpan _clockSkew; - public OAuthTokenHandler(OAuthConnectionInfo connectionInfo, HttpClient httpClient, TimeSpan? clockSkew = null) + public OAuthTokenHandler(OAuthConnectionInfo connectionInfo!!, HttpClient httpClient!!, TimeSpan? clockSkew = null) { - if (connectionInfo == null) throw new ArgumentNullException(nameof(connectionInfo)); if (string.IsNullOrEmpty(connectionInfo.ClientId)) throw new ArgumentNullException(nameof(connectionInfo.ClientId)); if (string.IsNullOrEmpty(connectionInfo.ClientSecret)) throw new ArgumentNullException(nameof(connectionInfo.ClientSecret)); _connectionInfo = connectionInfo; - _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + _httpClient = httpClient; _clockSkew = clockSkew.GetValueOrDefault(TimeSpan.FromMinutes(5)); } @@ -123,7 +122,7 @@ public string RefreshTokenIfNecessary(bool forceRefresh) // Therefore change the grant type to 'RefreshToken' only when the response includes a refresh token. if (!string.IsNullOrEmpty(_connectionInfo.RefreshToken)) _connectionInfo.GrantType = OAuthGrantType.RefreshToken; - _connectionInfo.OnTokenRefreshed(_connectionInfo.RefreshToken, _connectionInfo.AccessToken); + _connectionInfo.OnTokenRefreshed?.Invoke(_connectionInfo.RefreshToken, _connectionInfo.AccessToken); } finally { From 1afcc0099bfd18e499764c790711e05c16947bcc Mon Sep 17 00:00:00 2001 From: Jericho Date: Fri, 24 Jun 2022 10:33:39 -0400 Subject: [PATCH 28/32] Use ToEnumString extension method rather than manually converting an enum to its string equivalent --- Source/ZoomNet/Utilities/OAuthTokenHandler.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Source/ZoomNet/Utilities/OAuthTokenHandler.cs b/Source/ZoomNet/Utilities/OAuthTokenHandler.cs index b4a6d95a..c47fda7b 100644 --- a/Source/ZoomNet/Utilities/OAuthTokenHandler.cs +++ b/Source/ZoomNet/Utilities/OAuthTokenHandler.cs @@ -73,7 +73,7 @@ public string RefreshTokenIfNecessary(bool forceRefresh) { _lock.EnterWriteLock(); - var grantType = _connectionInfo.GrantType.GetAttributeOfType().Value; + var grantType = _connectionInfo.GrantType.ToEnumString(); var requestUrl = $"https://api.zoom.us/oauth/token?grant_type={grantType}"; switch (_connectionInfo.GrantType) { From 125c8f4e36f314021c8ab782cd80a339b87a7d87 Mon Sep 17 00:00:00 2001 From: Jericho Date: Wed, 29 Jun 2022 14:36:34 -0400 Subject: [PATCH 29/32] The Meetings.GetAllAsync method return summary information about meetings. It does not return all the available information about these meetings. --- Source/ZoomNet/Models/MeetingSummary.cs | 89 +++++++++++++++++++++++++ Source/ZoomNet/Resources/IMeetings.cs | 17 ++--- Source/ZoomNet/Resources/Meetings.cs | 32 ++------- 3 files changed, 104 insertions(+), 34 deletions(-) create mode 100644 Source/ZoomNet/Models/MeetingSummary.cs diff --git a/Source/ZoomNet/Models/MeetingSummary.cs b/Source/ZoomNet/Models/MeetingSummary.cs new file mode 100644 index 00000000..ff57ea86 --- /dev/null +++ b/Source/ZoomNet/Models/MeetingSummary.cs @@ -0,0 +1,89 @@ +using System; +using System.Text.Json.Serialization; + +namespace ZoomNet.Models +{ + /// + /// Summary information about a meeting. + /// + public class MeetingSummary + { + /// Gets or sets the meeting description. + /// + /// The length of agenda gets truncated to 250 characters + /// when you list all meetings for a user. To view the complete + /// agenda of a meeting, retrieve details for a single meeting, + /// use the Get a meeting API. + /// + [JsonPropertyName("agenda")] + public string Agenda { get; set; } + + /// + /// Gets or sets the date and time when the meeting was created. + /// + [JsonPropertyName("created_at")] + public DateTime CreatedOn { get; set; } + + /// + /// Gets or sets the duration in minutes. + /// + [JsonPropertyName("duration")] + public int Duration { get; set; } + + /// + /// Gets or sets the ID of the user who is set as the host of the meeting. + /// + [JsonPropertyName("host_id")] + public string HostId { get; set; } + + /// + /// Gets or sets the meeting id, also known as the meeting number. + /// + [JsonPropertyName("id")] + public long Id { get; set; } + + /// + /// Gets or sets the URL for the host to start the meeting. + /// + [JsonPropertyName("start_url")] + public string StartUrl { get; set; } + + /// + /// Gets or sets the personal meeting id. + /// + [JsonPropertyName("pmi")] + public string PersonalMeetingId { get; set; } + + /// + /// Gets or sets the start time. + /// + [JsonPropertyName("start_time")] + public DateTime StartTime { get; set; } + + /// + /// Gets or sets the timezone. + /// For example, "America/Los_Angeles". + /// Please reference our timezone list for supported timezones and their formats. + /// + [JsonPropertyName("timezone")] + public string Timezone { get; set; } + + /// + /// Gets or sets the topic of the meeting. + /// + [JsonPropertyName("topic")] + public string Topic { get; set; } + + /// + /// Gets or sets the meeting type. + /// + [JsonPropertyName("type")] + public MeetingType Type { get; set; } + + /// G + /// ets or sets the unique id. + /// + [JsonPropertyName("uuid")] + public string Uuid { get; set; } + } +} diff --git a/Source/ZoomNet/Resources/IMeetings.cs b/Source/ZoomNet/Resources/IMeetings.cs index 59c86483..374f12d2 100644 --- a/Source/ZoomNet/Resources/IMeetings.cs +++ b/Source/ZoomNet/Resources/IMeetings.cs @@ -15,7 +15,7 @@ namespace ZoomNet.Resources public interface IMeetings { /// - /// Retrieve all meetings of the specified type for a user. + /// Retrieve summary information about all meetings of the specified type for a user. /// /// The user Id or email address. /// The type of meetings. Allowed values: Scheduled, Live, Upcoming. @@ -23,16 +23,16 @@ public interface IMeetings /// The current page number of returned records. /// The cancellation token. /// - /// An array of meetings. + /// An array of meeting summaries. /// /// - /// This call omits 'occurrences'. Therefore the 'Occurrences' property will be empty. + /// To obtain the full details about a given meeting you must invoke . /// [Obsolete("Zoom is in the process of deprecating the \"page number\" and \"page count\" fields.")] - Task> GetAllAsync(string userId, MeetingListType type = MeetingListType.Scheduled, int recordsPerPage = 30, int page = 1, CancellationToken cancellationToken = default); + Task> GetAllAsync(string userId, MeetingListType type = MeetingListType.Scheduled, int recordsPerPage = 30, int page = 1, CancellationToken cancellationToken = default); /// - /// Retrieve all meetings of the specified type for a user. + /// Retrieve summary information about all meetings of the specified type for a user. /// /// The user Id or email address. /// The type of meetings. Allowed values: Scheduled, Live, Upcoming. @@ -40,12 +40,13 @@ public interface IMeetings /// The paging token. /// The cancellation token. /// - /// An array of meetings. + /// An array of meeting summaries. /// /// - /// This call omits 'occurrences'. Therefore the 'Occurrences' property will be empty. + /// To obtain the full details about a given meeting you must invoke . /// - Task> GetAllAsync(string userId, MeetingListType type = MeetingListType.Scheduled, int recordsPerPage = 30, string pagingToken = null, CancellationToken cancellationToken = default); + + Task> GetAllAsync(string userId, MeetingListType type = MeetingListType.Scheduled, int recordsPerPage = 30, string pagingToken = null, CancellationToken cancellationToken = default); /// /// Creates an instant meeting for a user. diff --git a/Source/ZoomNet/Resources/Meetings.cs b/Source/ZoomNet/Resources/Meetings.cs index b861ed68..f254e36f 100644 --- a/Source/ZoomNet/Resources/Meetings.cs +++ b/Source/ZoomNet/Resources/Meetings.cs @@ -29,19 +29,9 @@ internal Meetings(Pathoschild.Http.Client.IClient client) _client = client; } - /// - /// Retrieve all meetings of the specified type for a user. - /// - /// The user Id or email address. - /// The type of meetings. Allowed values: Scheduled, Live, Upcoming. - /// The number of records returned within a single API call. - /// The current page number of returned records. - /// The cancellation token. - /// - /// An array of . - /// + /// [Obsolete("Zoom is in the process of deprecating the \"page number\" and \"page count\" fields.")] - public Task> GetAllAsync(string userId, MeetingListType type = MeetingListType.Scheduled, int recordsPerPage = 30, int page = 1, CancellationToken cancellationToken = default) + public Task> GetAllAsync(string userId, MeetingListType type = MeetingListType.Scheduled, int recordsPerPage = 30, int page = 1, CancellationToken cancellationToken = default) { if (recordsPerPage < 1 || recordsPerPage > 300) { @@ -54,21 +44,11 @@ public Task> GetAllAsync(string userId, MeetingListTy .WithArgument("page_size", recordsPerPage) .WithArgument("page_number", page) .WithCancellationToken(cancellationToken) - .AsPaginatedResponse("meetings"); + .AsPaginatedResponse("meetings"); } - /// - /// Retrieve all meetings of the specified type for a user. - /// - /// The user Id or email address. - /// The type of meetings. Allowed values: Scheduled, Live, Upcoming. - /// The number of records returned within a single API call. - /// The paging token. - /// The cancellation token. - /// - /// An array of . - /// - public Task> GetAllAsync(string userId, MeetingListType type = MeetingListType.Scheduled, int recordsPerPage = 30, string pagingToken = null, CancellationToken cancellationToken = default) + /// + public Task> GetAllAsync(string userId, MeetingListType type = MeetingListType.Scheduled, int recordsPerPage = 30, string pagingToken = null, CancellationToken cancellationToken = default) { if (recordsPerPage < 1 || recordsPerPage > 300) { @@ -81,7 +61,7 @@ public Task> GetAllAsync(string userId, Meet .WithArgument("page_size", recordsPerPage) .WithArgument("next_page_token", pagingToken) .WithCancellationToken(cancellationToken) - .AsPaginatedResponseWithToken("meetings"); + .AsPaginatedResponseWithToken("meetings"); } /// From 00aa2741178ad72c9e7302ede3d3d3a08ba05290 Mon Sep 17 00:00:00 2001 From: Jericho Date: Wed, 29 Jun 2022 20:23:38 -0400 Subject: [PATCH 30/32] The Webinars.GetAllAsync method return summary information about webinars. It does not return all the available information about these webinars. --- Source/ZoomNet/Models/WebinarSummary.cs | 98 +++++++++++++++++++++++++ Source/ZoomNet/Resources/IWebinars.cs | 18 +++-- Source/ZoomNet/Resources/Webinars.cs | 30 ++------ 3 files changed, 116 insertions(+), 30 deletions(-) create mode 100644 Source/ZoomNet/Models/WebinarSummary.cs diff --git a/Source/ZoomNet/Models/WebinarSummary.cs b/Source/ZoomNet/Models/WebinarSummary.cs new file mode 100644 index 00000000..fa7766b5 --- /dev/null +++ b/Source/ZoomNet/Models/WebinarSummary.cs @@ -0,0 +1,98 @@ +using System; +using System.Text.Json.Serialization; + +namespace ZoomNet.Models +{ + /// + /// Summary information about a webinar. + /// + public class WebinarSummary + { + /// + /// Gets or sets the webinar agenda. + /// + /// The agenda. + [JsonPropertyName("agenda")] + public string Agenda { get; set; } + + /// + /// Gets or sets the date and time when the meeting was created. + /// + /// The meeting created time. + [JsonPropertyName("created_at")] + public DateTime CreatedOn { get; set; } + + /// + /// Gets or sets the duration in minutes. + /// + /// The duration in minutes. + [JsonPropertyName("duration")] + public int Duration { get; set; } + + /// + /// Gets or sets the ID of the user who is set as the host of the webinar. + /// + /// + /// The user id. + /// + [JsonPropertyName("host_id")] + public string HostId { get; set; } + + /// + /// Gets or sets the webinar id, also known as the webinar number. + /// + /// + /// The id. + /// + [JsonPropertyName("id")] + public long Id { get; set; } + + /// + /// Gets or sets the URL to join the webinar. + /// + /// The join URL. + [JsonPropertyName("join_url")] + public string JoinUrl { get; set; } + + /// + /// Gets or sets the webinar start time. + /// + /// The webinar start time. + [JsonPropertyName("start_time")] + public DateTime StartTime { get; set; } + + /// + /// Gets or sets the timezone. + /// For example, "America/Los_Angeles". + /// Please reference our timezone list for supported timezones and their formats. + /// + /// The webinar timezone. For example, "America/Los_Angeles". Please reference our timezone list for supported timezones and their formats. + [JsonPropertyName("timezone")] + public string Timezone { get; set; } + + /// + /// Gets or sets the topic of the meeting. + /// + /// + /// The topic. + /// + [JsonPropertyName("topic")] + public string Topic { get; set; } + + /// + /// Gets or sets the webinar type. + /// + /// The webinar type. + [JsonPropertyName("type")] + public WebinarType Type { get; set; } + + /// + /// Gets or sets the unique id. + /// + /// + /// The unique id. + /// + [JsonPropertyName("uuid")] + public string Uuid { get; set; } + } +} diff --git a/Source/ZoomNet/Resources/IWebinars.cs b/Source/ZoomNet/Resources/IWebinars.cs index 1e53afa9..1797b70c 100644 --- a/Source/ZoomNet/Resources/IWebinars.cs +++ b/Source/ZoomNet/Resources/IWebinars.cs @@ -15,29 +15,35 @@ namespace ZoomNet.Resources public interface IWebinars { /// - /// Retrieve all webinars for a user. + /// Retrieve summary information about all webinars for a user. /// /// The user Id or email address. /// The number of records returned within a single API call. /// The current page number of returned records. /// The cancellation token. /// - /// An array of webinars. + /// An array of webinar summaries. /// + /// + /// To obtain the full details about a given webinar you must invoke . + /// [Obsolete("Zoom is in the process of deprecating the \"page number\" and \"page count\" fields.")] - Task> GetAllAsync(string userId, int recordsPerPage = 30, int page = 1, CancellationToken cancellationToken = default); + Task> GetAllAsync(string userId, int recordsPerPage = 30, int page = 1, CancellationToken cancellationToken = default); /// - /// Retrieve all webinars for a user. + /// Retrieve summary information about all webinars for a user. /// /// The user Id or email address. /// The number of records returned within a single API call. /// The paging token. /// The cancellation token. /// - /// An array of webinars. + /// An array of webinar summaries. /// - Task> GetAllAsync(string userId, int recordsPerPage = 30, string pagingToken = null, CancellationToken cancellationToken = default); + /// + /// To obtain the full details about a given webinar you must invoke . + /// + Task> GetAllAsync(string userId, int recordsPerPage = 30, string pagingToken = null, CancellationToken cancellationToken = default); /// /// Creates a scheduled webinar for a user. diff --git a/Source/ZoomNet/Resources/Webinars.cs b/Source/ZoomNet/Resources/Webinars.cs index 6d944b8d..20498a8a 100644 --- a/Source/ZoomNet/Resources/Webinars.cs +++ b/Source/ZoomNet/Resources/Webinars.cs @@ -29,18 +29,9 @@ internal Webinars(Pathoschild.Http.Client.IClient client) _client = client; } - /// - /// Retrieve all webinars for a user. - /// - /// The user Id or email address. - /// The number of records returned within a single API call. - /// The current page number of returned records. - /// The cancellation token. - /// - /// An array of . - /// + /// [Obsolete("Zoom is in the process of deprecating the \"page number\" and \"page count\" fields.")] - public Task> GetAllAsync(string userId, int recordsPerPage = 30, int page = 1, CancellationToken cancellationToken = default) + public Task> GetAllAsync(string userId, int recordsPerPage = 30, int page = 1, CancellationToken cancellationToken = default) { if (recordsPerPage < 1 || recordsPerPage > 300) { @@ -52,20 +43,11 @@ public Task> GetAllAsync(string userId, int recordsPe .WithArgument("page_size", recordsPerPage) .WithArgument("page_number", page) .WithCancellationToken(cancellationToken) - .AsPaginatedResponse("webinars"); + .AsPaginatedResponse("webinars"); } - /// - /// Retrieve all webinars for a user. - /// - /// The user Id or email address. - /// The number of records returned within a single API call. - /// The paging token. - /// The cancellation token. - /// - /// An array of . - /// - public Task> GetAllAsync(string userId, int recordsPerPage = 30, string pagingToken = null, CancellationToken cancellationToken = default) + /// + public Task> GetAllAsync(string userId, int recordsPerPage = 30, string pagingToken = null, CancellationToken cancellationToken = default) { if (recordsPerPage < 1 || recordsPerPage > 300) { @@ -77,7 +59,7 @@ public Task> GetAllAsync(string userId, int .WithArgument("page_size", recordsPerPage) .WithArgument("next_page_token", pagingToken) .WithCancellationToken(cancellationToken) - .AsPaginatedResponseWithToken("webinars"); + .AsPaginatedResponseWithToken("webinars"); } /// From 06e3969f2596b5243f79ed4f61c741730ecf8d8a Mon Sep 17 00:00:00 2001 From: Jericho Date: Wed, 29 Jun 2022 20:24:48 -0400 Subject: [PATCH 31/32] Fix integration tests --- Source/ZoomNet.IntegrationTests/Tests/Meetings.cs | 1 + Source/ZoomNet.IntegrationTests/Tests/Webinars.cs | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/Source/ZoomNet.IntegrationTests/Tests/Meetings.cs b/Source/ZoomNet.IntegrationTests/Tests/Meetings.cs index ce274174..1fb92a36 100644 --- a/Source/ZoomNet.IntegrationTests/Tests/Meetings.cs +++ b/Source/ZoomNet.IntegrationTests/Tests/Meetings.cs @@ -29,6 +29,7 @@ public async Task RunAsync(User myUser, string[] myPermissions, IZoomClient clie // CLEANUP PREVIOUS INTEGRATION TESTS THAT MIGHT HAVE BEEN INTERRUPTED BEFORE THEY HAD TIME TO CLEANUP AFTER THEMSELVES var cleanUpTasks = paginatedScheduledMeetings.Records .Union(paginatedLiveMeetings.Records) + .Union(paginatedUpcomingMeetings.Records) .Where(m => m.Topic.StartsWith("ZoomNet Integration Testing:")) .Select(async oldMeeting => { diff --git a/Source/ZoomNet.IntegrationTests/Tests/Webinars.cs b/Source/ZoomNet.IntegrationTests/Tests/Webinars.cs index 6a680eaf..1d48c530 100644 --- a/Source/ZoomNet.IntegrationTests/Tests/Webinars.cs +++ b/Source/ZoomNet.IntegrationTests/Tests/Webinars.cs @@ -22,7 +22,6 @@ public async Task RunAsync(User myUser, string[] myPermissions, IZoomClient clie // CLEANUP PREVIOUS INTEGRATION TESTS THAT MIGHT HAVE BEEN INTERRUPTED BEFORE THEY HAD TIME TO CLEANUP AFTER THEMSELVES var cleanUpTasks = paginatedWebinars.Records - .Union(paginatedWebinars.Records) .Where(m => m.Topic.StartsWith("ZoomNet Integration Testing:")) .Select(async oldWebinar => { From 8eb0fc171200a2c936d307a5c96da77a646f44d6 Mon Sep 17 00:00:00 2001 From: Jericho Date: Wed, 29 Jun 2022 20:36:04 -0400 Subject: [PATCH 32/32] Fix XML comments --- Source/ZoomNet/Models/MeetingSummary.cs | 4 ++-- Source/ZoomNet/Resources/IMeetings.cs | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/Source/ZoomNet/Models/MeetingSummary.cs b/Source/ZoomNet/Models/MeetingSummary.cs index ff57ea86..086571a1 100644 --- a/Source/ZoomNet/Models/MeetingSummary.cs +++ b/Source/ZoomNet/Models/MeetingSummary.cs @@ -80,8 +80,8 @@ public class MeetingSummary [JsonPropertyName("type")] public MeetingType Type { get; set; } - /// G - /// ets or sets the unique id. + /// + /// Gets or sets the unique id. /// [JsonPropertyName("uuid")] public string Uuid { get; set; } diff --git a/Source/ZoomNet/Resources/IMeetings.cs b/Source/ZoomNet/Resources/IMeetings.cs index 374f12d2..71a0d0d1 100644 --- a/Source/ZoomNet/Resources/IMeetings.cs +++ b/Source/ZoomNet/Resources/IMeetings.cs @@ -45,7 +45,6 @@ public interface IMeetings /// /// To obtain the full details about a given meeting you must invoke . /// - Task> GetAllAsync(string userId, MeetingListType type = MeetingListType.Scheduled, int recordsPerPage = 30, string pagingToken = null, CancellationToken cancellationToken = default); ///