ZoomNet 0.80.0

dotnet add package ZoomNet --version 0.80.0                
NuGet\Install-Package ZoomNet -Version 0.80.0                
此命令旨在在 Visual Studio 的包管理器控制台中使用,因为它使用 NuGet 模块的 Install-Package 版本。
<PackageReference Include="ZoomNet" Version="0.80.0" />                
对于支持 PackageReference 的项目,请将此 XML 节点复制到项目文件中以引用包。
paket add ZoomNet --version 0.80.0                
#r "nuget: ZoomNet, 0.80.0"                
#r 指令可用于 F# Interactive 和多语言笔记本。请将此内容复制到交互工具或脚本的源代码中以引用包。
// Install ZoomNet as a Cake Addin
#addin nuget:?package=ZoomNet&version=0.80.0

// Install ZoomNet as a Cake Tool
#tool nuget:?package=ZoomNet&version=0.80.0                

ZoomNet

License Sourcelink

Build status Tests Coverage Status CodeFactor

发布说明 NuGet (稳定版) MyGet (预发布版)
GitHub release NuGet Version MyGet Pre Release

关于

  • ZoomNet 库简化了与 Zoom.us API 的连接以及与各种端点的交互。
  • 此库还包括一个解析器,允许您处理由 Zoom API 通过 HTTP 发送给您的入站 webhook 消息。
  • Zoom 正在开发一个新服务器,该服务器将通过 WebSocket 而不是 HTTP 传递 webhook 消息。此服务器在 2022 年夏季的 beta 测试中引入,截至 2023 年 1 月,它仍在 beta 测试中。ZoomNet 库提供了一个方便的客户端,使您能够接收和处理这些消息。

安装

将 ZoomNet 包含到您的 C# 项目中最简单的方法是将此 NuGet 包添加到项目

PM> Install-Package ZoomNet

.NET 框架支持

ZoomNet 目前支持

  • .NET 框架 4.8
  • 支持 .NET Standard 2.1 的任何框架(包括 .NET Core 3.xASP.NET Core 3.x
  • .NET 6.0
  • .NET 7.0

支持 .NET 4.6.1.NET 4.7.2.NET Standard 2.0 的最后一个 ZoomNet 版本是 0.35.0

使用方法

连接信息

在开始使用 ZoomNet 客户端之前,您必须决定如何连接到 Zoom API。ZoomNet 支持三种连接到 Zoom 的方式:JWT、OAuth 和服务器到服务器的 OAuth。

使用 JWT 连接

这是连接到 Zoom API 的最简单方式。Zoom 预期您使用一个密钥和一个秘密来生成一个带有签名载荷的 JSON 对象,并在每个 API 请求中提供这个 JSON 对象。好消息是,ZoomNet 会处理生成此 JSON 对象的复杂性:您只需提供密钥和秘密,ZoomNet 会处理其余部分。超级简单!

正如 Zoom 文档中提到的,这对于您需要构建一个使用服务器到服务器交互的 Zoom API 的应用程序来说是完美的。

以下是 Zoom 文档中关于 如何获取您的 API 密钥和秘密 的摘录

JWT 应用需要 API 密钥和秘密来进行 JWT 验证。要访问 API 密钥和秘密,您需要在市场中创建一个 JWT 应用程序。在提供有关您应用程序的基本信息后,您可以在“应用程序凭据”页面中找到您的 API 密钥和秘密。

当您有了 API 密钥和秘密后,您可以创建一个如下所示的“连接信息”对象

var apiKey = "... your API key ...";
var apiSecret = "... your API secret ...";
var connectionInfo = new JwtConnectionInfo(apiKey, apiSecret);
var zoomClient = new ZoomClient(connectionInfo);

警告: <a href="https://marketplace.zoom.us/docs/guides/build/jwt-app/jwt-faq/">Zoom 已宣布</a>,此认证方法将于 2023 年 6 月被弃用。建议切换到服务器到服务器 OAuth。

使用 OAuth 连接

使用 OAuth 比使用 JWT 更复杂,但同时它也更灵活,因为您可以为应用程序定义所需的权限。当一个用户安装您的应用程序时,他会看到您的应用程序所需的权限列表,并且有接受的机会。

Zoom 文档中有一篇关于 创建 OAuth 应用程序 的文档和另一篇关于 OAuth 授权流程 的文档,但我个人很困惑,因此这里是一个简要的步骤总结

  • 您创建一个 OAuth 应用程序,定义应用程序所需的权限,并将应用程序发布到 Zoom 市场中。
  • 用户安装您的应用程序。在安装过程中,用户会看到列有应用程序所需权限的屏幕。用户必须点击 接受
  • Zoom 会生成一个“授权码”。此代码只能使用一次,用于生成第一个访问令牌和刷新令牌。我必须强调这一点:授权码只能使用一次。这是我感到困惑的部分:我并没有理解这个代码只能使用一次,我试图重复使用它。Zoom 在第一次接受该代码后,会拒绝后续请求,这导致我在尝试找出为什么代码有时被拒绝时浪费了许多时间。
  • 访问令牌的有效期为 60 分钟,因此必须定期“刷新”。

当您首次将 OAuth 应用程序添加到您的 Zoom 账户时,您将获得一个“授权码”。您可以像这样为 ZoomNet 提供这个授权码

var clientId = "... your client ID ...";
var clientSecret = "... your client secret ...";
var authorizationCode = "... the code that Zoom issued when you added the OAuth app to your account ...";
var redirectUri = "... the URI you have configured when setting up your OAuth app ..."; // Please note that Zoom sometimes accepts a null value and sometimes rejects it with a 'Redirect URI mismatch' error
var connectionInfo = OAuthConnectionInfo.WithAuthorizationCode(clientId, clientSecret, authorizationCode,
    (newRefreshToken, newAccessToken) =>
    {
        /*
            This callback is invoked when the authorization code
            is converted into an access token and also when the
            access token is subsequently refreshed.

            You should use this callback to save the refresh token
            to a safe place so you can provide it the next time you
            need to instantiate an OAuthConnectionInfo.
            
            The access token on the other hand does not need to be
            preserved because it is ephemeral (meaning it expires
            after 60 minutes). Even if you preserve it, it is very
            likely to be expired (and therefore useless) before the
            next time you need to instantiate an OAuthConnectionInfo.

            For demonstration purposes, here's how you could use your
            operating system's environment variables to store the token:
        */
        Environment.SetEnvironmentVariable("ZOOM_OAUTH_REFRESHTOKEN", newRefreshToken, EnvironmentVariableTarget.User);
    },
    redirectUri);
var zoomClient = new ZoomClient(connectionInfo);

警告:我刚刚提供的示例只能在 Zoom 生成新的授权码时使用。ZoomNet 会将其转换为访问令牌,此时授权码就不再有效。

一旦授权码被转换为访问token和刷新token,您就可以创建一个这样的'连接信息'对象:

var clientId = "... your client ID ...";
var clientSecret = "... your client secret ...";
var refreshToken = Environment.GetEnvironmentVariable("ZOOM_OAUTH_REFRESHTOKEN", EnvironmentVariableTarget.User);
var connectionInfo = OAuthConnectionInfo.WithRefreshToken(clientId, clientSecret, refreshToken,
    (newRefreshToken, newAccessToken) =>
    {
        /*
            As previously stated, it's important to preserve the refresh token.
        */
        Environment.SetEnvironmentVariable("ZOOM_OAUTH_REFRESHTOKEN", newRefreshToken, EnvironmentVariableTarget.User);
    });
var zoomClient = new ZoomClient(connectionInfo);
使用服务器到服务器的OAuth进行连接

此认证方法是JWT认证的替代方案,Zoom宣布将在2023年6月将其淘汰。

出自Zoom的文档

服务器到服务器的OAuth应用程序使您可以安全地集成Zoom API,并获得您的账户所有者访问token,而无需用户交互。这与需要用户认证的OAuth应用程序类型不同。请参阅使用OAuth 2.0以获取详细信息。

ZoomNet负责获取新的访问token,并在token到期时刷新之前发放的token(服务器到服务器的访问token有效期为一小时)。

var clientId = "... your client ID ...";
var clientSecret = "... your client secret ...";
var accountId = "... your account id ...";
var connectionInfo = OAuthConnectionInfo.ForServerToServer(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 it.

            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.
        */
    });
var zoomClient = new ZoomClient(connectionInfo);

在服务器到服务器场景中,代理是可选的,因此您可以简化连接信息的声明如下:

var connectionInfo = OAuthConnectionInfo.ForServerToServer(clientId, clientSecret, accountId);
var zoomClient = new ZoomClient(connectionInfo);
服务器到服务器OAuth场景中的应用程序多个实例

重要提示 此处关于如何防止多个应用程序(或单个应用程序的多个实例)相互取消OAuth token效果的讨论已过时,因为自2023年6月起。请参阅此公告。讨论中提到的三种潜在解决方案现在都变得不再必要,因为Zoom已更改了其平台的某些行为,这是造成此问题的根源。特别是,解决方案2中提到的“token索引”已在ZoomNet版本0.64.0中删除,而解决方案3中提到的更先进的解决方案(曾作为测试版提供)现已放弃。

<strike>

关于服务器到服务器OAuth的一个重要细节,但不为众人所熟知的是,请求新的token会自动使之前发放的token无效,即使它尚未达到到期日期/时间。如果您的应用程序同时运行多个实例,这将影响您。为了说明这意味着什么,让我们假设您同时运行了您的应用程序的两个实例。将要发生的是,实例1将请求一个新的token,它将在一段时间内成功使用,直到实例2请求它自己的token。当第二个token被发放时,实例1的token将被无效化,这将导致实例1请求一个新的token。这个新的token将使token2无效化,这将导致实例2请求新的token,依此类推。正如您所看到的,实例1和2正在为了token而互相斗争。

有几种方法可以克服这个问题

解决方案1:您可以在Zoom的管理控制台中创建多个OAuth应用程序,为您的应用程序的每个实例创建一个。这意味着每个实例都将有它们自己的clientId、clientSecret和accountId,因此它们可以独立请求token而不会相互干扰。

这要求您创建和管理这些Zoom应用程序。此外,您还需要确保您的C#代码中的OAuthConnectionInfo为每个实例初始化适当的值。当您的实例数量相对较少时,这是一个简单而有效的解决方案,但在我看来,当实例数量变得很大时,这可能会变得令人难以承受。

解决方案2:创建单个Zoom OAuth应用程序。联系Zoom支持,并为此OAuth应用程序请求额外的“token索引”(也称为“组号”)。随后,新的token可以被“限定于”给定的索引,这意味着为特定索引发放的token不会使任何其他索引的token无效。希望Zoom会授予您足够的token索引,并且您可以专门为每个应用程序的实例分配一个索引,然后您可以修改您的C#代码,以限制您的OAuth连接到所需的索引,如下所示:

// you initialize the connection info for your first instance like this:
var connectionInfo = OAuthConnectionInfo.ForServerToServer(clientId, clientSecret, accountId, 0);

// for your second instance, like this:
var connectionInfo = OAuthConnectionInfo.ForServerToServer(clientId, clientSecret, accountId, 1);

// instance number 3:
var connectionInfo = OAuthConnectionInfo.ForServerToServer(clientId, clientSecret, accountId, 2);

... and so on ...

与解决方案1一样,当您有相对较少的实例并且Zoom已授予您足够的索引时,该解决方案工作良好。

但是如果你的应用程序实例数量超过了Zoom授予你的索引数量,那怎么办呢?比如说,如果你在云中(Azure、AWS等)有100个应用程序实例在运行,但Zoom只授予你5个索引,你处于这种情况时,我之前提出的方法都无法解决这个问题。继续阅读,下一个解决方案将是一个对你来说更好的选项。

解决方案3号:你可以通过将令牌信息存储在所有实例都能访问到的公共仓库中,确保所有实例共享相同的令牌。这类仓库的例子有:Azure blob存储、SQL服务器、Redis、MySQL等。为了确保这类解决方案的有效性,我们还需要确保实例不会同时请求新的令牌,因为这再次会引发前面提到的问题,即每个新的令牌都使之前的令牌失效。

如果这个解决方案看起来适合你的场景,那么你很幸运:有一个ZoomNet的测试版本提供了必要的基础设施。你只需要编写一个接口的实现来提供你在仓库中保存并使所有应用程序实例可访问的令牌信息的逻辑。ZoomNet负责确保在任意时刻只允许你的一个实例刷新令牌。如果你有兴趣测试这个测试版,请在这里留言

</strike>

Webhook解析器

以下是一个.NET 6.0 API控制器的基本示例,该控制器解析Zoom的webhook。

using Microsoft.AspNetCore.Mvc;

namespace WebApplication1.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class ZoomWebhooksController : ControllerBase
    {
        [HttpPost]
        public async Task<IActionResult> ReceiveEvent()
        {
            var parser = new ZoomNet.WebhookParser();
            var event = await parser.ParseEventWebhookAsync(Request.Body).ConfigureAwait(false);

            // ... do something with the event ...

            return Ok();
        }
    }
}

在解析之前确保webhook来自Zoom

你可以验证webhook是从Zoom发出的,合法的。任何未能通过验证的webhook都应被视为可疑的,并列弃。

要开始,你需要确保你的Zoom Marketplace应用程序与一个Secret Token相关联(如果你的应用程序还没有这样的令牌,请点击'Regenerate'按钮),如截图所示。 截图

当你的Marketplace应用程序有一个Secret Token时,Zoom将在发送到您的端点的请求中包含两个额外的标题,您必须使用这些标题中的值来验证您收到的内容是否合法。如果您想知道如何处理这两个值来判断webhook是否合法,请查看文档中的这一页。但,ZoomNet致力于使您的生活更简单,因此我们已实现这一逻辑。

WebhookParser类有一个名为VerifyAndParseEventWebhookAsync的方法,它将自动验证数据。如果验证失败,会抛出一个安全异常。如果验证失败,你应该认为webhook数据无效。以下是它是如何工作的

using Microsoft.AspNetCore.Mvc;

namespace WebApplication1.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class ZoomWebhookController : ControllerBase
    {
        [HttpPost]
        public async Task<IActionResult> ReceiveEvent()
        {
            // Your webhook app's secret token
            var secretToken = "... your app's secret token ...";

            // Get the signature and the timestamp from the request headers
            // SIGNATURE_HEADER_NAME and TIMESTAMP_HEADER_NAME are two convenient constants provided by ZoomNet so you don't have to remember the actual names of the headers
            var signature = Request.Headers[ZoomNet.WebhookParser.SIGNATURE_HEADER_NAME].SingleOrDefault();
            var timestamp = Request.Headers[ZoomNet.WebhookParser.TIMESTAMP_HEADER_NAME].SingleOrDefault();

            var parser = new ZoomNet.WebhookParser();

            // The signature will be automatically validated and a security exception thrown if unable to validate
            var zoomEvent = await parser.VerifyAndParseEventWebhookAsync(Request.Body, secretToken, signature, timestamp).ConfigureAwait(false);

            // ... do something with the event...

            return Ok();
        }
    }
}

响应Zoom 对您的 webhook 端点的请求进行验证

当您最初配置您想让Zoom发布的webhook的URL时,Zoom将向此URL发送一个请求,您需要以一种可以被Zoom API验证的方式响应此验证挑战。Zoom称这为“挑战-响应检查(CRC)”。假设初始验证成功,Zoom API将每72小时重复此验证过程。当然,您可以通过遵循Zoom的说明手动构建此响应。但是,如果您想避免了解Zoom期望的响应的复杂性,并且只想方便地生成此响应,ZoomNet可以帮您!EndpointUrlValidationEvent类有一个名为GenerateUrlValidationResponse的方法,它将生成您必须在HTTP 200响应中包括的字符串。以下是它是如何工作的

using Microsoft.AspNetCore.Mvc;

namespace WebApplication1.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class ZoomWebhooksController : ControllerBase
    {
        [HttpPost]
        public async Task<IActionResult> ReceiveEvent()
        {
            // Your webhook app's secret token
            var secretToken = "... your app's secret token ...";

            var parser = new ZoomNet.WebhookParser();
            var event = await parser.ParseEventWebhookAsync(Request.Body).ConfigureAwait(false);

            var endpointUrlValidationEvent = zoomEvent as EndpointUrlValidationEvent;
            var responsePayload = endpointUrlValidationEvent.GenerateUrlValidationResponse(secretToken);
            return Ok(responsePayload);
        }
    }
}

终极webhook控制器

以下是“终极”webhook控制器,它结合了上述所有功能。

using Microsoft.AspNetCore.Mvc;

namespace WebApplication1.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class ZoomWebhooksController : ControllerBase
    {
        [HttpPost]
        public async Task<IActionResult> ReceiveEvent()
        {
            // Your webhook app's secret token
            var secretToken = "... your app's secret token ...";

            // SIGNATURE_HEADER_NAME and TIMESTAMP_HEADER_NAME are two convenient constants provided by ZoomNet so you don't have to remember the actual name of the headers
            var signature = Request.Headers[ZoomNet.WebhookParser.SIGNATURE_HEADER_NAME].SingleOrDefault();
            var timestamp = Request.Headers[ZoomNet.WebhookParser.TIMESTAMP_HEADER_NAME].SingleOrDefault();

            var parser = new ZoomNet.WebhookParser();
            Event zoomEvent;

            if (!string.IsNullOrEmpty(signature) && !string.IsNullOrEmpty(timestamp))
            {
                try
                {
                    zoomEvent = await parser.VerifyAndParseEventWebhookAsync(Request.Body, secretToken, signature, timestamp).ConfigureAwait(false);
                }
                catch (SecurityException e)
                {
                    // Unable to validate the data. Therefore you should consider the request as suspicious
                    throw;
                }
            }
            else
            {
                zoomEvent = await parser.ParseEventWebhookAsync(Request.Body).ConfigureAwait(false);
            }

            if (zoomEvent.EventType == EventType.EndpointUrlValidation)
            {
                // It's important to include the payload along with your HTTP200 response. This is how you let Zoom know that your URL is valid
                var endpointUrlValidationEvent = zoomEvent as EndpointUrlValidationEvent;
                var responsePayload = endpointUrlValidationEvent.GenerateUrlValidationResponse(secretToken);
                return Ok(responsePayload);
            }
            else
            {
                // ... do something with the event ...

                return Ok();
            }
        }
    }
}

通过WebSockets进行Webhooks

截至本文写作时(2022年10月),基于WebSocket的webhooks正处于公测阶段,如果您想参与公测,可以注册(请参见这里)。

ZoomNet提供了一个方便的客户端,用于接收和处理通过WebSocket连接接收到的webhooks事件。此WebSocket客户端将自动管理连接,确保在连接因某些原因关闭时重新建立连接。另外,它将管理OAuth令牌,并在令牌过期时自动刷新。

以下是如何在C#控制台应用程序中使用该工具的方法

using System.Net;
using ZoomNet;
using ZoomNet.Models.Webhooks;

var clientId = "... your client id ...";
var clientSecret = "... your client secret ...";
var accountId = "... your account id ...";
var subscriptionId = "... your subscription id ..."; // See instructions below how to get this value

// This is the async delegate that gets invoked when a webhook event is received
var eventProcessor = new Func<Event, CancellationToken, Task>(async (webhookEvent, cancellationToken) =>
{
    if (!cancellationToken.IsCancellationRequested)
    {
        // Add your custom logic to process this event
    }
});

// Configure cancellation (this allows you to press CTRL+C or CTRL+Break to stop the websocket client)
var cts = new CancellationTokenSource();
var exitEvent = new ManualResetEvent(false);
Console.CancelKeyPress += (s, e) =>
{
    e.Cancel = true;
    cts.Cancel();
    exitEvent.Set();
};

// Start the websocket client
var connectionInfo = OAuthConnectionInfo.ForServerToServer(clientId, clientSecret, accountId);
using (var client = new ZoomWebSocketClient(connectionInfo, subscriptionId, eventProcessor, proxy, logger))
{
    await client.StartAsync(cts.Token).ConfigureAwait(false);
    exitEvent.WaitOne();
}
如何获取您的WebSocket订阅ID

当您在Zoom Marketplace中配置WebSocket的webhooks时,Zoom会生成一个URL,就像本截图所示

Screenshot

您的订阅ID是URL的最后一部分。在上面的示例中,生成的URL类似于wss://api.zoom.us/v2/webhooks/events?subscription_id=1234567890,因此订阅ID为1234567890

产品 兼容和额外的计算目标框架版本。
.NET net5.0 已计算。 net5.0-windows 已计算。 net6.0 兼容。 net6.0-android 已计算。 net6.0-ios 已计算。 net6.0-maccatalyst 已计算。 net6.0-macos 已计算。 net6.0-tvos 已计算。 net6.0-windows 已计算。 net7.0 兼容。 net7.0-android 已计算。 net7.0-ios 已计算。 net7.0-maccatalyst 已计算。 net7.0-macos 已计算。 net7.0-tvos 已计算。 net7.0-windows 已计算。 net8.0 已计算。 net8.0-android 已计算。 net8.0-browser 已计算。 net8.0-ios 已计算。 net8.0-maccatalyst 已计算。 net8.0-macos 已计算。 net8.0-tvos 已计算。 net8.0-windows 已计算。
.NET Core netcoreapp3.0 已计算。 netcoreapp3.1 已计算。
.NET Standard netstandard2.1 是兼容的。
.NET Framework net48 是兼容的。 net481 已计算。
MonoAndroid monoandroid 已计算。
MonoMac monomac 已计算。
MonoTouch monotouch 已计算。
Tizen tizen60 已计算。
Xamarin.iOS xamarinios 已计算。
Xamarin.Mac xamarinmac 已计算。
Xamarin.TVOS xamarintvos 已计算。
Xamarin.WatchOS xamarinwatchos 已计算。
兼容的目标框架
包含的目标框架(在包中)
了解更多关于 目标框架.NET Standard 的信息。

NuGet 包

此包未被任何 NuGet 包使用。

GitHub 仓库

此包未被任何流行的 GitHub 仓库使用。

版本 下载 最后更新
0.80.0 220 8/7/2024
0.79.0 300 7/23/2024
0.78.0 460 7/12/2024
0.77.0 1,323 6/7/2024
0.76.0 4,444 4/27/2024
0.75.2 1,900 4/11/2024
0.75.1 277 4/8/2024
0.75.0 143 4/6/2024
0.74.0 15,429 2/16/2024
0.73.0 5,555 1/20/2024
0.72.0 3,346 12/9/2023
0.71.0 739 12/8/2023
0.70.0 1,884 11/21/2023
0.69.0 1,400 11/18/2023
0.68.0 2,471 11/2/2023
0.67.0 5,394 9/22/2023
0.66.0 9,119 7/30/2023
0.65.0 1,384 7/13/2023
0.64.0 794 6/26/2023
0.63.1 2,237 5/25/2023
0.63.0 2,134 5/23/2023
0.62.0 1,437 5/13/2023
0.61.0 2,762 5/1/2023
0.60.0 2,034 4/15/2023
0.59.1 893 3/30/2023
0.59.0 550 3/29/2023
0.58.0 5,071 1/26/2023
0.57.0 643 1/23/2023
0.56.0 916 1/11/2023
0.55.0 5,725 12/23/2022
0.54.0 693 12/14/2022
0.53.0 5,962 11/22/2022
0.52.0 991 10/27/2022
0.51.1 2,572 9/26/2022
0.50.0 5,311 8/24/2022
0.49.0 1,416 8/16/2022
0.48.0 749 8/15/2022
0.47.0 1,200 8/3/2022
0.46.0 1,157 7/16/2022
0.45.0 847 7/8/2022
0.44.0 793 6/30/2022
0.43.0 4,203 5/31/2022
0.42.3 9,889 4/27/2022
0.42.2 715 4/25/2022
0.42.1 11,066 3/12/2022
0.42.0 3,610 3/7/2022
0.41.0 4,220 3/1/2022
0.40.0 941 2/22/2022
0.39.0 1,738 2/4/2022
0.38.0 754 1/31/2022
0.37.0 1,091 1/15/2022
0.36.0 8,134 11/29/2021
0.35.0 969 11/17/2021
0.34.0 1,902 10/25/2021
0.33.0 3,370 10/4/2021
0.32.4 781 9/21/2021
0.32.3 696 9/21/2021
0.32.2 7,059 9/16/2021
0.32.1 601 9/15/2021
0.31.0 25,359 6/7/2021
0.30.0 795 6/1/2021
0.29.0 23,872 5/12/2021
0.28.0 677 5/11/2021
0.27.2 2,642 4/7/2021
0.27.1 978 3/26/2021
0.27.0 667 3/25/2021
0.26.0 849 3/18/2021
0.25.0 934 3/4/2021
0.24.0 763 3/1/2021
0.23.0 90,969 2/27/2021
0.22.0 666 2/27/2021
0.21.0 1,007 2/18/2021
0.20.0 684 2/13/2021
0.19.0 827 2/9/2021
0.18.0 1,879 1/26/2021
0.17.0 736 1/22/2021
0.16.0 1,751 1/20/2021
0.15.0 4,414 1/14/2021
0.14.0 751 1/12/2021
0.13.0 719 1/12/2021
0.12.0 749 1/6/2021
0.11.0 894 12/31/2020
0.10.0 1,430 11/22/2020
0.9.0 12,981 10/2/2020
0.8.3 2,211 9/9/2020
0.8.2 746 9/8/2020
0.8.1 793 9/7/2020
0.7.0 748 8/29/2020
0.6.0 25,969 6/30/2020
0.5.1 820 6/17/2020
0.5.0 776 6/15/2020
0.4.1 845 6/1/2020
0.4.0 804 5/27/2020
0.3.0 744 5/26/2020
0.2.0 785 5/11/2020
0.1.0 795 5/6/2020