feat: determine if notification is for stream or not
This commit is contained in:
@@ -1,18 +1,50 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
builder.Services.Configure<HostOptions>(
|
||||||
|
static options => options.ShutdownTimeout = TimeSpan.FromSeconds(30)
|
||||||
|
);
|
||||||
|
|
||||||
builder.Services
|
builder.Services
|
||||||
.AddOptionsWithValidateOnStart<SubscriptionOptions>()
|
.AddOptionsWithValidateOnStart<SubscriptionOptions>()
|
||||||
.BindConfiguration(nameof(SubscriptionOptions))
|
.BindConfiguration(nameof(SubscriptionOptions))
|
||||||
.ValidateDataAnnotations();
|
.ValidateDataAnnotations();
|
||||||
|
|
||||||
|
builder.Services
|
||||||
|
.AddOptionsWithValidateOnStart<YouTubeClientOptions>()
|
||||||
|
.BindConfiguration(nameof(YouTubeClientOptions))
|
||||||
|
.ValidateDataAnnotations();
|
||||||
|
|
||||||
|
builder.Services
|
||||||
|
.AddOptionsWithValidateOnStart<PubSubClientOptions>()
|
||||||
|
.BindConfiguration(nameof(PubSubClientOptions))
|
||||||
|
.ValidateDataAnnotations();
|
||||||
|
|
||||||
builder.Services
|
builder.Services
|
||||||
.AddHttpClient<IPubSubClient, PubSubClient>(
|
.AddHttpClient<IPubSubClient, PubSubClient>(
|
||||||
static c => c.BaseAddress = new("https://pubsubhubbub.appspot.com")
|
static (sp, c) =>
|
||||||
|
{
|
||||||
|
var options = sp.GetRequiredService<IOptions<PubSubClientOptions>>().Value;
|
||||||
|
c.BaseAddress = new(options.BaseUrl);
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.AddStandardResilienceHandler();
|
||||||
|
|
||||||
|
builder.Services
|
||||||
|
.AddHttpClient<IYouTubeDataApiClient, YouTubeDataApiClient>(
|
||||||
|
static (sp, c) =>
|
||||||
|
{
|
||||||
|
var options = sp.GetRequiredService<IOptions<YouTubeClientOptions>>().Value;
|
||||||
|
c.BaseAddress = new(options.BaseUrl);
|
||||||
|
}
|
||||||
)
|
)
|
||||||
.AddStandardResilienceHandler();
|
.AddStandardResilienceHandler();
|
||||||
|
|
||||||
builder.Services.AddOpenApi();
|
builder.Services.AddOpenApi();
|
||||||
|
|
||||||
|
builder.Services.AddSingleton(TimeProvider.System);
|
||||||
|
builder.Services.AddSingleton<ConcurrentQueue<SubscribeTask>>();
|
||||||
builder.Services.AddHostedService<SubscriptionWorker>();
|
builder.Services.AddHostedService<SubscriptionWorker>();
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
@@ -22,17 +54,52 @@ if (app.Environment.IsDevelopment())
|
|||||||
app.MapOpenApi();
|
app.MapOpenApi();
|
||||||
}
|
}
|
||||||
|
|
||||||
app.UseHttpsRedirection();
|
// app.UseHttpsRedirection();
|
||||||
|
|
||||||
const string ytCallback = "yt-callback";
|
const string ytCallback = "yt-callback";
|
||||||
|
|
||||||
app.MapGet(ytCallback, VerifySubscriptionHandler.HandleAsync);
|
app.MapGet(ytCallback, VerifySubscriptionHandler.HandleAsync);
|
||||||
|
|
||||||
// TODO: Implement logic to do the following:
|
// TODO: Implement logic to do the following:
|
||||||
// - extract video id from notification
|
|
||||||
// - identify video as a stream or not
|
|
||||||
// - if is stream create discord message
|
// - if is stream create discord message
|
||||||
// - if not then just log a message
|
app.MapPost(ytCallback, static async (HttpContext context, [FromServices] ILogger<Program> logger, [FromServices] IYouTubeDataApiClient youTubeDataApiClient) =>
|
||||||
app.MapPost(ytCallback, static () => "hello");
|
{
|
||||||
|
var body = "";
|
||||||
|
using StreamReader stream = new(context.Request.Body);
|
||||||
|
body = await stream.ReadToEndAsync();
|
||||||
|
|
||||||
|
var videoIdRegex = VideoIdRegex();
|
||||||
|
var match = videoIdRegex.Match(body);
|
||||||
|
|
||||||
|
if (match.Success is false)
|
||||||
|
{
|
||||||
|
logger.LogWarning("No video ID found in the request body.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var videoId = match.Groups[1].Value;
|
||||||
|
var video = await youTubeDataApiClient.GetVideoByIdAsync(videoId, ["liveStreamingDetails"]);
|
||||||
|
|
||||||
|
if (video is null)
|
||||||
|
{
|
||||||
|
logger.LogWarning("Video with ID {VideoId} not found.", videoId);
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (video.IsStream is false)
|
||||||
|
{
|
||||||
|
logger.LogInformation("Video ID {VideoId} is not a live stream.", videoId);
|
||||||
|
return Results.Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogInformation("Video ID {VideoId} is a live stream.", videoId);
|
||||||
|
|
||||||
|
return Results.Ok();
|
||||||
|
});
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|
||||||
|
internal partial class Program
|
||||||
|
{
|
||||||
|
[GeneratedRegex(@"<yt:videoId>(.*?)</yt:videoId>")]
|
||||||
|
private static partial Regex VideoIdRegex();
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
|
global using System.Collections.Concurrent;
|
||||||
global using System.ComponentModel.DataAnnotations;
|
global using System.ComponentModel.DataAnnotations;
|
||||||
|
global using System.Text.Json.Serialization;
|
||||||
|
|
||||||
global using Microsoft.AspNetCore.Mvc;
|
global using Microsoft.AspNetCore.Mvc;
|
||||||
global using Microsoft.Extensions.Options;
|
global using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
global using StevesBot.Webhook;
|
|
||||||
global using StevesBot.Webhook.YouTube;
|
global using StevesBot.Webhook.YouTube;
|
||||||
|
global using StevesBot.Webhook.YouTube.Data;
|
||||||
|
global using StevesBot.Webhook.YouTube.Tasks;
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace StevesBot.Webhook.YouTube.Data;
|
||||||
|
|
||||||
|
internal interface IYouTubeDataApiClient
|
||||||
|
{
|
||||||
|
Task<YouTubeVideo?> GetVideoByIdAsync(
|
||||||
|
string videoId,
|
||||||
|
string[]? part = null,
|
||||||
|
CancellationToken cancellationToken = default
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
using Microsoft.AspNetCore.WebUtilities;
|
||||||
|
|
||||||
|
namespace StevesBot.Webhook.YouTube.Data;
|
||||||
|
|
||||||
|
internal sealed class YouTubeDataApiClient(
|
||||||
|
HttpClient httpClient,
|
||||||
|
ILogger<YouTubeDataApiClient> logger,
|
||||||
|
IOptions<YouTubeClientOptions> options
|
||||||
|
) : IYouTubeDataApiClient
|
||||||
|
{
|
||||||
|
private const string VideosEndpoint = "videos";
|
||||||
|
private readonly HttpClient _httpClient = httpClient;
|
||||||
|
private readonly ILogger<YouTubeDataApiClient> _logger = logger;
|
||||||
|
private readonly YouTubeClientOptions _options = options.Value;
|
||||||
|
|
||||||
|
public async Task<YouTubeVideo?> GetVideoByIdAsync(
|
||||||
|
string videoId,
|
||||||
|
string[]? part = null,
|
||||||
|
CancellationToken cancellationToken = default
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var queryParams = new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["id"] = videoId,
|
||||||
|
["key"] = _options.ApiKey,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (part is not null && part.Length > 0)
|
||||||
|
{
|
||||||
|
queryParams["part"] = string.Join(',', part);
|
||||||
|
}
|
||||||
|
|
||||||
|
var requestEndpoint = QueryHelpers.AddQueryString(VideosEndpoint, queryParams);
|
||||||
|
var requestUri = new Uri(requestEndpoint, UriKind.Relative);
|
||||||
|
var response = await _httpClient.GetAsync(requestUri, cancellationToken);
|
||||||
|
|
||||||
|
if (response.IsSuccessStatusCode is false)
|
||||||
|
{
|
||||||
|
var content = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
|
||||||
|
_logger.LogDebug(
|
||||||
|
"Failed to get video by ID {VideoId} from YouTube Data API. Status code: {StatusCode}, Content: {Content}",
|
||||||
|
videoId,
|
||||||
|
response.StatusCode,
|
||||||
|
content
|
||||||
|
);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var responseContent = await response.Content.ReadFromJsonAsync<YouTubeVideoListResponse>(cancellationToken);
|
||||||
|
return responseContent?.Items.FirstOrDefault(i => i.Id == videoId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace StevesBot.Webhook.YouTube.Data;
|
||||||
|
|
||||||
|
internal sealed record YouTubeLiveStreamingDetails
|
||||||
|
{
|
||||||
|
[JsonPropertyName("actualStartTime")]
|
||||||
|
public DateTimeOffset? ActualStartTime { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("actualEndTime")]
|
||||||
|
public DateTimeOffset? ActualEndTime { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("scheduledStartTime")]
|
||||||
|
public DateTimeOffset? ScheduledStartTime { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("scheduledEndTime")]
|
||||||
|
public DateTimeOffset? ScheduledEndTime { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("concurrentViewers")]
|
||||||
|
public ulong? ConcurrentViewers { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("activeLiveChatId")]
|
||||||
|
public string? ActiveLiveChatId { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace StevesBot.Webhook.YouTube.Data;
|
||||||
|
|
||||||
|
internal sealed record YouTubePageInfo
|
||||||
|
{
|
||||||
|
[JsonPropertyName("totalResults")]
|
||||||
|
public int TotalResults { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("resultsPerPage")]
|
||||||
|
public int ResultsPerPage { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace StevesBot.Webhook.YouTube.Data;
|
||||||
|
|
||||||
|
internal sealed record YouTubeVideo
|
||||||
|
{
|
||||||
|
[JsonPropertyName("id")]
|
||||||
|
public string Id { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("liveStreamingDetails")]
|
||||||
|
public YouTubeLiveStreamingDetails? LiveStreamingDetails { get; init; }
|
||||||
|
|
||||||
|
public bool IsStream => LiveStreamingDetails is not null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace StevesBot.Webhook.YouTube.Data;
|
||||||
|
|
||||||
|
internal sealed record YouTubeVideoListResponse
|
||||||
|
{
|
||||||
|
[JsonPropertyName("items")]
|
||||||
|
public YouTubeVideo[] Items { get; init; } = [];
|
||||||
|
|
||||||
|
[JsonPropertyName("pageInfo")]
|
||||||
|
public YouTubePageInfo PageInfo { get; init; } = new YouTubePageInfo();
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace StevesBot.Webhook.YouTube;
|
||||||
|
|
||||||
|
internal sealed class PubSubClientOptions
|
||||||
|
{
|
||||||
|
public string BaseUrl { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -3,15 +3,29 @@ namespace StevesBot.Webhook.YouTube;
|
|||||||
internal sealed class SubscriptionWorker(
|
internal sealed class SubscriptionWorker(
|
||||||
ILogger<SubscriptionWorker> logger,
|
ILogger<SubscriptionWorker> logger,
|
||||||
IOptions<SubscriptionOptions> options,
|
IOptions<SubscriptionOptions> options,
|
||||||
IPubSubClient pubSubClient
|
IPubSubClient pubSubClient,
|
||||||
) : IHostedLifecycleService
|
ConcurrentQueue<SubscribeTask> subscriptionQueue,
|
||||||
|
TimeProvider timeProvider
|
||||||
|
) : IHostedLifecycleService, IDisposable
|
||||||
{
|
{
|
||||||
private readonly ILogger<SubscriptionWorker> _logger = logger;
|
private readonly ILogger<SubscriptionWorker> _logger = logger;
|
||||||
private readonly SubscriptionOptions _options = options.Value;
|
private readonly SubscriptionOptions _options = options.Value;
|
||||||
private readonly IPubSubClient _pubSubClient = pubSubClient;
|
private readonly IPubSubClient _pubSubClient = pubSubClient;
|
||||||
|
private readonly ConcurrentQueue<SubscribeTask> _subscriptionQueue = subscriptionQueue;
|
||||||
|
private readonly TimeProvider _timeProvider = timeProvider;
|
||||||
|
private ITimer? _subscriptionTimer;
|
||||||
|
|
||||||
public Task StartAsync(CancellationToken cancellationToken)
|
public Task StartAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
_logger.LogInformation("Starting subscription worker");
|
||||||
|
|
||||||
|
_subscriptionTimer = _timeProvider.CreateTimer(
|
||||||
|
callback: async _ => await ProcessSubscriptionQueueAsync(cancellationToken),
|
||||||
|
state: null,
|
||||||
|
dueTime: TimeSpan.FromSeconds(0),
|
||||||
|
period: TimeSpan.FromDays(1)
|
||||||
|
);
|
||||||
|
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,6 +56,7 @@ internal sealed class SubscriptionWorker(
|
|||||||
|
|
||||||
public Task StopAsync(CancellationToken cancellationToken)
|
public Task StopAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
_subscriptionTimer?.Change(Timeout.InfiniteTimeSpan, TimeSpan.Zero);
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,4 +69,52 @@ internal sealed class SubscriptionWorker(
|
|||||||
{
|
{
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task ProcessSubscriptionQueueAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
while (_subscriptionQueue.TryDequeue(out var task))
|
||||||
|
{
|
||||||
|
// We will attempt to process the task
|
||||||
|
// once every day so we will aim to
|
||||||
|
// process it two days before it expires.
|
||||||
|
var now = _timeProvider.GetUtcNow();
|
||||||
|
var dueAt = task.ExpiresAt - TimeSpan.FromDays(2);
|
||||||
|
var isDue = now >= dueAt;
|
||||||
|
|
||||||
|
if (isDue is false)
|
||||||
|
{
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Skipping subscription task for topic {TopicUrl} as it is not due yet. Due at: {DueAt}, Current time: {CurrentTime}",
|
||||||
|
task.TopicUrl,
|
||||||
|
dueAt,
|
||||||
|
now
|
||||||
|
);
|
||||||
|
|
||||||
|
_subscriptionQueue.Enqueue(task);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Processing subscription task for topic: {TopicUrl}", task.TopicUrl);
|
||||||
|
|
||||||
|
var isSubscribed = await _pubSubClient.SubscribeAsync(
|
||||||
|
task.CallbackUrl,
|
||||||
|
task.TopicUrl,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isSubscribed)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Successfully subscribed to topic: {TopicUrl}", task.TopicUrl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogWarning("Failed to subscribe to topic: {TopicUrl}", task.TopicUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_subscriptionTimer?.Dispose();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
namespace StevesBot.Webhook.YouTube.Tasks;
|
namespace StevesBot.Webhook.YouTube.Tasks;
|
||||||
|
|
||||||
internal sealed record SubscribeTask : SubscriptionTask
|
internal sealed record SubscribeTask
|
||||||
{
|
{
|
||||||
|
public string CallbackUrl { get; init; } = string.Empty;
|
||||||
|
public string TopicUrl { get; init; } = string.Empty;
|
||||||
public DateTimeOffset ExpiresAt { get; init; }
|
public DateTimeOffset ExpiresAt { get; init; }
|
||||||
}
|
}
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
namespace StevesBot.Webhook.YouTube.Tasks;
|
|
||||||
|
|
||||||
internal abstract record SubscriptionTask
|
|
||||||
{
|
|
||||||
public string CallbackUrl { get; init; } = string.Empty;
|
|
||||||
public string TopicUrl { get; init; } = string.Empty;
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
namespace StevesBot.Webhook.YouTube.Tasks;
|
|
||||||
|
|
||||||
internal sealed record UnsubscribeTask : SubscriptionTask
|
|
||||||
{
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,8 @@ internal static class VerifySubscriptionHandler
|
|||||||
[FromQuery(Name = "hub.challenge")] string? challenge,
|
[FromQuery(Name = "hub.challenge")] string? challenge,
|
||||||
[FromQuery(Name = "hub.lease_seconds")] string? leaseSeconds,
|
[FromQuery(Name = "hub.lease_seconds")] string? leaseSeconds,
|
||||||
[FromServices] IOptions<SubscriptionOptions> subOptions,
|
[FromServices] IOptions<SubscriptionOptions> subOptions,
|
||||||
[FromServices] ILogger<Program> logger
|
[FromServices] ILogger<Program> logger,
|
||||||
|
[FromServices] ConcurrentQueue<SubscribeTask> subscriptionQueue
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
if (mode is "denied")
|
if (mode is "denied")
|
||||||
@@ -26,23 +27,31 @@ internal static class VerifySubscriptionHandler
|
|||||||
|
|
||||||
if (mode is "subscribe")
|
if (mode is "subscribe")
|
||||||
{
|
{
|
||||||
// TODO: If it is a subscription request
|
|
||||||
// we need to queue up a resubscription
|
|
||||||
// request to be executed just before
|
|
||||||
// the hub.lease expires.
|
|
||||||
logger.LogInformation(
|
logger.LogInformation(
|
||||||
"Received subscription request for topic: {Topic}, challenge: {Challenge}, lease: {LeaseSeconds}",
|
"Received subscription request for topic: {Topic}, challenge: {Challenge}, lease: {LeaseSeconds}",
|
||||||
topic,
|
topic,
|
||||||
challenge,
|
challenge,
|
||||||
leaseSeconds
|
leaseSeconds
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(leaseSeconds) || !long.TryParse(leaseSeconds, out var parsedSeconds))
|
||||||
|
{
|
||||||
|
logger.LogWarning("Invalid or missing lease_seconds parameter: {LeaseSeconds}", leaseSeconds);
|
||||||
|
return Results.BadRequest("Invalid lease_seconds parameter");
|
||||||
|
}
|
||||||
|
|
||||||
|
var task = new SubscribeTask
|
||||||
|
{
|
||||||
|
CallbackUrl = subOptions.Value.CallbackUrl,
|
||||||
|
TopicUrl = topic,
|
||||||
|
ExpiresAt = DateTime.UtcNow.AddSeconds(parsedSeconds),
|
||||||
|
};
|
||||||
|
|
||||||
|
subscriptionQueue.Enqueue(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mode is "unsubscribe")
|
if (mode is "unsubscribe")
|
||||||
{
|
{
|
||||||
// TODO: If it is a unsubscription request
|
|
||||||
// we need to queue up an ubsubscription
|
|
||||||
// request to be executed immediately
|
|
||||||
logger.LogInformation(
|
logger.LogInformation(
|
||||||
"Received unsubscription request for topic: {Topic}, challenge: {Challenge}",
|
"Received unsubscription request for topic: {Topic}, challenge: {Challenge}",
|
||||||
topic,
|
topic,
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace StevesBot.Webhook.YouTube;
|
||||||
|
|
||||||
|
internal sealed record YouTubeClientOptions
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
public string BaseUrl { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public string ApiKey { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -1,6 +1,20 @@
|
|||||||
{
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*",
|
||||||
|
"YouTubeClientOptions": {
|
||||||
|
"BaseUrl": "BaseUrl",
|
||||||
|
"ApiKey": "ApiKey"
|
||||||
|
},
|
||||||
"SubscriptionOptions": {
|
"SubscriptionOptions": {
|
||||||
"CallbackUrl": "CallbackUrl",
|
"CallbackUrl": "CallbackUrl",
|
||||||
"TopicUrl": "TopicUrl"
|
"TopicUrl": "TopicUrl"
|
||||||
|
},
|
||||||
|
"PubSubClientOptions": {
|
||||||
|
"BaseUrl": "BaseUrl"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
public class UnitTest1
|
public class UnitTest1
|
||||||
{
|
{
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Test1()
|
public void Test1()
|
||||||
{
|
{
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user