Merge pull request #587 from Qtoss-AI/master
Add ExcludeResponseUrls to PageActionArgs
This commit is contained in:
commit
2be4dd3053
|
|
@ -4,6 +4,7 @@ namespace BotSharp.Abstraction.Browsing;
|
||||||
|
|
||||||
public interface IWebBrowser
|
public interface IWebBrowser
|
||||||
{
|
{
|
||||||
|
void SetServiceProvider (IServiceProvider services);
|
||||||
Task<BrowserActionResult> LaunchBrowser(MessageInfo message, BrowserActionArgs args);
|
Task<BrowserActionResult> LaunchBrowser(MessageInfo message, BrowserActionArgs args);
|
||||||
Task<BrowserActionResult> ScreenshotAsync(MessageInfo message, string path);
|
Task<BrowserActionResult> ScreenshotAsync(MessageInfo message, string path);
|
||||||
Task<BrowserActionResult> ScrollPage(MessageInfo message, PageActionArgs args);
|
Task<BrowserActionResult> ScrollPage(MessageInfo message, PageActionArgs args);
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,22 @@ public class PageActionArgs
|
||||||
public string? Direction { get; set; }
|
public string? Direction { get; set; }
|
||||||
|
|
||||||
public string Url { get; set; } = null!;
|
public string Url { get; set; } = null!;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// This value has to be set to true if you want to get the page XHR/ Fetch responses
|
||||||
|
/// </summary>
|
||||||
public bool OpenNewTab { get; set; } = false;
|
public bool OpenNewTab { get; set; } = false;
|
||||||
|
/// <summary>
|
||||||
|
/// Exclude urls for XHR/ Fetch responses
|
||||||
|
/// </summary>
|
||||||
|
public string[]? ExcludeResponseUrls { get; set; }
|
||||||
|
public bool UseExistingPage { get; set; } = false;
|
||||||
|
|
||||||
public bool WaitForNetworkIdle { get; set; } = true;
|
public bool WaitForNetworkIdle { get; set; } = true;
|
||||||
public float? Timeout { get; set; }
|
public float? Timeout { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wait time in seconds after page is opened
|
||||||
|
/// </summary>
|
||||||
|
public int WaitTime { get; set; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,4 +9,5 @@ public interface IUserIdentity
|
||||||
string LastName { get; }
|
string LastName { get; }
|
||||||
string FullName { get; }
|
string FullName { get; }
|
||||||
string? UserLanguage { get; }
|
string? UserLanguage { get; }
|
||||||
|
string? Phone { get; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -183,7 +183,7 @@
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Aspects.Cache" Version="2.0.4" />
|
<PackageReference Include="Aspects.Cache" Version="2.0.4" />
|
||||||
<PackageReference Include="DistributedLock.Redis" Version="1.0.3" />
|
<PackageReference Include="DistributedLock.Redis" Version="1.0.3" />
|
||||||
<PackageReference Include="EntityFrameworkCore.BootKit" Version="8.5.0" />
|
<PackageReference Include="EntityFrameworkCore.BootKit" Version="8.5.1" />
|
||||||
<PackageReference Include="Fluid.Core" Version="2.11.1" />
|
<PackageReference Include="Fluid.Core" Version="2.11.1" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" />
|
||||||
<PackageReference Include="Nanoid" Version="3.1.0" />
|
<PackageReference Include="Nanoid" Version="3.1.0" />
|
||||||
|
|
|
||||||
|
|
@ -66,4 +66,7 @@ public class UserIdentity : IUserIdentity
|
||||||
return languages.FirstOrDefault();
|
return languages.FirstOrDefault();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[JsonPropertyName("phone")]
|
||||||
|
public string? Phone => _claims?.FirstOrDefault(x => x.Type == "phone")?.Value;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -192,7 +192,8 @@ public class UserService : IUserService
|
||||||
new Claim(JwtRegisteredClaimNames.FamilyName, user?.LastName ?? string.Empty),
|
new Claim(JwtRegisteredClaimNames.FamilyName, user?.LastName ?? string.Empty),
|
||||||
new Claim("source", user.Source),
|
new Claim("source", user.Source),
|
||||||
new Claim("external_id", user.ExternalId ?? string.Empty),
|
new Claim("external_id", user.ExternalId ?? string.Empty),
|
||||||
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
|
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||||
|
new Claim("phone", user.Phone ?? string.Empty)
|
||||||
};
|
};
|
||||||
|
|
||||||
var validators = _services.GetServices<IAuthenticationHook>();
|
var validators = _services.GetServices<IAuthenticationHook>();
|
||||||
|
|
@ -204,11 +205,12 @@ public class UserService : IUserService
|
||||||
var config = _services.GetRequiredService<IConfiguration>();
|
var config = _services.GetRequiredService<IConfiguration>();
|
||||||
var issuer = config["Jwt:Issuer"];
|
var issuer = config["Jwt:Issuer"];
|
||||||
var audience = config["Jwt:Audience"];
|
var audience = config["Jwt:Audience"];
|
||||||
|
var expireInMinutes = int.Parse(config["Jwt:ExpireInMinutes"] ?? "120");
|
||||||
var key = Encoding.ASCII.GetBytes(config["Jwt:Key"]);
|
var key = Encoding.ASCII.GetBytes(config["Jwt:Key"]);
|
||||||
var tokenDescriptor = new SecurityTokenDescriptor
|
var tokenDescriptor = new SecurityTokenDescriptor
|
||||||
{
|
{
|
||||||
Subject = new ClaimsIdentity(claims),
|
Subject = new ClaimsIdentity(claims),
|
||||||
Expires = DateTime.UtcNow.AddHours(2),
|
Expires = DateTime.UtcNow.AddMinutes(expireInMinutes),
|
||||||
Issuer = issuer,
|
Issuer = issuer,
|
||||||
Audience = audience,
|
Audience = audience,
|
||||||
SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(key),
|
SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(key),
|
||||||
|
|
|
||||||
|
|
@ -11,15 +11,17 @@
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<Compile Remove="Drivers\SeleniumDriver\**" />
|
||||||
<Compile Remove="packages\**" />
|
<Compile Remove="packages\**" />
|
||||||
|
<EmbeddedResource Remove="Drivers\SeleniumDriver\**" />
|
||||||
<EmbeddedResource Remove="packages\**" />
|
<EmbeddedResource Remove="packages\**" />
|
||||||
|
<None Remove="Drivers\SeleniumDriver\**" />
|
||||||
<None Remove="packages\**" />
|
<None Remove="packages\**" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.Playwright" Version="1.45.1" />
|
<PackageReference Include="Microsoft.Playwright" Version="1.45.1" />
|
||||||
<PackageReference Include="Selenium.WebDriver" Version="4.23.0" />
|
<PackageReference Include="HtmlAgilityPack" Version="1.11.62" />
|
||||||
<PackageReference Include="HtmlAgilityPack" Version="1.11.61" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,11 @@ namespace BotSharp.Plugin.WebDriver.Drivers.PlaywrightDriver;
|
||||||
public class PlaywrightInstance : IDisposable
|
public class PlaywrightInstance : IDisposable
|
||||||
{
|
{
|
||||||
IPlaywright _playwright;
|
IPlaywright _playwright;
|
||||||
|
IServiceProvider _services;
|
||||||
|
public IServiceProvider Services => _services;
|
||||||
Dictionary<string, IBrowserContext> _contexts = new Dictionary<string, IBrowserContext>();
|
Dictionary<string, IBrowserContext> _contexts = new Dictionary<string, IBrowserContext>();
|
||||||
Dictionary<string, List<IPage>> _pages = new Dictionary<string, List<IPage>>();
|
Dictionary<string, List<IPage>> _pages = new Dictionary<string, List<IPage>>();
|
||||||
|
IPage? _activePage = null;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// ContextId and BrowserContext
|
/// ContextId and BrowserContext
|
||||||
|
|
@ -18,9 +21,29 @@ public class PlaywrightInstance : IDisposable
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Dictionary<string, List<IPage>> Pages => _pages;
|
public Dictionary<string, List<IPage>> Pages => _pages;
|
||||||
|
|
||||||
public IPage GetPage(string id, string? pattern = null)
|
public void SetServiceProvider(IServiceProvider services)
|
||||||
{
|
{
|
||||||
return _contexts[id].Pages.LastOrDefault();
|
_services = services;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IPage? GetPage(string contextId, string? pattern = null)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(pattern))
|
||||||
|
{
|
||||||
|
return _activePage ?? _contexts[contextId].Pages.LastOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var page in _contexts[contextId].Pages)
|
||||||
|
{
|
||||||
|
if (page.Url.ToLower() == pattern.ToLower())
|
||||||
|
{
|
||||||
|
_activePage = page;
|
||||||
|
page.BringToFrontAsync().Wait();
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return _contexts[contextId].Pages.LastOrDefault();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IBrowserContext> GetContext(string ctxId)
|
public async Task<IBrowserContext> GetContext(string ctxId)
|
||||||
|
|
@ -64,6 +87,7 @@ public class PlaywrightInstance : IDisposable
|
||||||
|
|
||||||
_contexts[ctxId].Page += async (sender, page) =>
|
_contexts[ctxId].Page += async (sender, page) =>
|
||||||
{
|
{
|
||||||
|
_activePage = page;
|
||||||
_pages[ctxId].Add(page);
|
_pages[ctxId].Add(page);
|
||||||
page.Close += async (sender, e) =>
|
page.Close += async (sender, e) =>
|
||||||
{
|
{
|
||||||
|
|
@ -93,7 +117,7 @@ public class PlaywrightInstance : IDisposable
|
||||||
return _contexts[ctxId];
|
return _contexts[ctxId];
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IPage> NewPage(MessageInfo message, IServiceProvider services)
|
public async Task<IPage> NewPage(MessageInfo message, string[]? excludeResponseUrls = null)
|
||||||
{
|
{
|
||||||
var context = await GetContext(message.ContextId);
|
var context = await GetContext(message.ContextId);
|
||||||
var page = await context.NewPageAsync();
|
var page = await context.NewPageAsync();
|
||||||
|
|
@ -105,9 +129,11 @@ public class PlaywrightInstance : IDisposable
|
||||||
|
|
||||||
page.Response += async (sender, e) =>
|
page.Response += async (sender, e) =>
|
||||||
{
|
{
|
||||||
if (e.Headers.ContainsKey("content-type") &&
|
if (e.Status != 204 &&
|
||||||
|
e.Headers.ContainsKey("content-type") &&
|
||||||
e.Headers["content-type"].Contains("application/json") &&
|
e.Headers["content-type"].Contains("application/json") &&
|
||||||
(e.Request.ResourceType == "fetch" || e.Request.ResourceType == "xhr"))
|
(e.Request.ResourceType == "fetch" || e.Request.ResourceType == "xhr") &&
|
||||||
|
(excludeResponseUrls == null || !excludeResponseUrls.Any(url => e.Url.ToLower().Contains(url))))
|
||||||
{
|
{
|
||||||
Serilog.Log.Information($"{e.Request.Method}: {e.Url}");
|
Serilog.Log.Information($"{e.Request.Method}: {e.Url}");
|
||||||
JsonElement? json = null;
|
JsonElement? json = null;
|
||||||
|
|
@ -122,13 +148,17 @@ public class PlaywrightInstance : IDisposable
|
||||||
Serilog.Log.Warning($"Response status: {e.Status} {e.StatusText}, OK: {e.Ok}");
|
Serilog.Log.Warning($"Response status: {e.Status} {e.StatusText}, OK: {e.Ok}");
|
||||||
}
|
}
|
||||||
|
|
||||||
var webPageResponseHooks = services.GetServices<IWebPageResponseHook>();
|
var webPageResponseHooks = _services.GetServices<IWebPageResponseHook>();
|
||||||
foreach (var hook in webPageResponseHooks)
|
foreach (var hook in webPageResponseHooks)
|
||||||
{
|
{
|
||||||
hook.OnDataFetched(message, e.Url.ToLower(), e.Request?.PostData ?? string.Empty, JsonSerializer.Serialize(json));
|
hook.OnDataFetched(message, e.Url.ToLower(), e.Request?.PostData ?? string.Empty, JsonSerializer.Serialize(json));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch(Exception ex)
|
catch (ObjectDisposedException ex)
|
||||||
|
{
|
||||||
|
Serilog.Log.Information(ex.Message);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Serilog.Log.Error(ex.ToString());
|
Serilog.Log.Error(ex.ToString());
|
||||||
}
|
}
|
||||||
|
|
@ -174,6 +204,7 @@ public class PlaywrightInstance : IDisposable
|
||||||
if (page != null)
|
if (page != null)
|
||||||
{
|
{
|
||||||
await page.CloseAsync();
|
await page.CloseAsync();
|
||||||
|
_activePage = _pages[ctxId].LastOrDefault();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,26 +8,24 @@ public partial class PlaywrightWebDriver
|
||||||
var context = await _instance.GetContext(message.ContextId);
|
var context = await _instance.GetContext(message.ContextId);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Check if the page is already open
|
var page = args.UseExistingPage ?
|
||||||
/*if (!args.OpenNewTab && context.Pages.Count > 0)
|
_instance.GetPage(message.ContextId, pattern: args.Url) :
|
||||||
{
|
await _instance.NewPage(message, excludeResponseUrls: args.ExcludeResponseUrls);
|
||||||
foreach (var p in context.Pages)
|
|
||||||
{
|
|
||||||
if (p.Url == args.Url)
|
|
||||||
{
|
|
||||||
// Disable this due to performance issue, some page is too large
|
|
||||||
// result.Body = await p.ContentAsync();
|
|
||||||
result.IsSuccess = true;
|
|
||||||
// await p.BringToFrontAsync();
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}*/
|
|
||||||
|
|
||||||
var page = args.OpenNewTab ? await _instance.NewPage(message, _services) :
|
if (args.UseExistingPage && page != null && page.Url == args.Url)
|
||||||
_instance.GetPage(message.ContextId);
|
{
|
||||||
|
Serilog.Log.Information($"goto existing page: {args.Url}");
|
||||||
|
result.IsSuccess = true;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
Serilog.Log.Information($"goto page: {args.Url}");
|
Serilog.Log.Information($"goto page: {args.Url}");
|
||||||
|
|
||||||
|
if (args.UseExistingPage && args.OpenNewTab && page != null && page.Url == "about:blank")
|
||||||
|
{
|
||||||
|
page = await _instance.NewPage(message, excludeResponseUrls: args.ExcludeResponseUrls);
|
||||||
|
}
|
||||||
|
|
||||||
var response = await page.GotoAsync(args.Url, new PageGotoOptions
|
var response = await page.GotoAsync(args.Url, new PageGotoOptions
|
||||||
{
|
{
|
||||||
Timeout = args.Timeout
|
Timeout = args.Timeout
|
||||||
|
|
@ -42,6 +40,11 @@ public partial class PlaywrightWebDriver
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (args.WaitTime > 0)
|
||||||
|
{
|
||||||
|
await Task.Delay(args.WaitTime * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
if (response.Status == 200)
|
if (response.Status == 200)
|
||||||
{
|
{
|
||||||
// Disable this due to performance issue, some page is too large
|
// Disable this due to performance issue, some page is too large
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ namespace BotSharp.Plugin.WebDriver.Drivers.PlaywrightDriver;
|
||||||
|
|
||||||
public partial class PlaywrightWebDriver : IWebBrowser
|
public partial class PlaywrightWebDriver : IWebBrowser
|
||||||
{
|
{
|
||||||
private readonly IServiceProvider _services;
|
private IServiceProvider _services => _instance.Services;
|
||||||
private readonly PlaywrightInstance _instance;
|
private readonly PlaywrightInstance _instance;
|
||||||
private readonly ILogger _logger;
|
private readonly ILogger _logger;
|
||||||
public PlaywrightInstance Instance => _instance;
|
public PlaywrightInstance Instance => _instance;
|
||||||
|
|
@ -12,9 +12,9 @@ public partial class PlaywrightWebDriver : IWebBrowser
|
||||||
|
|
||||||
public PlaywrightWebDriver(IServiceProvider services, PlaywrightInstance instance, ILogger<PlaywrightWebDriver> logger)
|
public PlaywrightWebDriver(IServiceProvider services, PlaywrightInstance instance, ILogger<PlaywrightWebDriver> logger)
|
||||||
{
|
{
|
||||||
_services = services;
|
|
||||||
_instance = instance;
|
_instance = instance;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_instance.SetServiceProvider(services);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void SetAgent(Agent agent)
|
public void SetAgent(Agent agent)
|
||||||
|
|
@ -63,4 +63,9 @@ public partial class PlaywrightWebDriver : IWebBrowser
|
||||||
|
|
||||||
return element;
|
return element;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void SetServiceProvider(IServiceProvider services)
|
||||||
|
{
|
||||||
|
_instance.SetServiceProvider(_services);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -81,4 +81,9 @@ public partial class SeleniumWebDriver : IWebBrowser
|
||||||
{
|
{
|
||||||
throw new NotImplementedException();
|
throw new NotImplementedException();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void SetServiceProvider(IServiceProvider services)
|
||||||
|
{
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ global using Microsoft.Playwright;
|
||||||
global using Microsoft.Extensions.Configuration;
|
global using Microsoft.Extensions.Configuration;
|
||||||
global using Microsoft.Extensions.DependencyInjection;
|
global using Microsoft.Extensions.DependencyInjection;
|
||||||
global using Microsoft.Extensions.Logging;
|
global using Microsoft.Extensions.Logging;
|
||||||
global using OpenQA.Selenium;
|
|
||||||
global using BotSharp.Abstraction.Browsing.Enums;
|
global using BotSharp.Abstraction.Browsing.Enums;
|
||||||
global using BotSharp.Abstraction.Conversations;
|
global using BotSharp.Abstraction.Conversations;
|
||||||
global using BotSharp.Abstraction.Plugins;
|
global using BotSharp.Abstraction.Plugins;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
using BotSharp.Abstraction.Browsing.Settings;
|
using BotSharp.Abstraction.Browsing.Settings;
|
||||||
using BotSharp.Abstraction.Settings;
|
using BotSharp.Abstraction.Settings;
|
||||||
using BotSharp.Plugin.WebDriver.Drivers.PlaywrightDriver;
|
using BotSharp.Plugin.WebDriver.Drivers.PlaywrightDriver;
|
||||||
using BotSharp.Plugin.WebDriver.Drivers.SeleniumDriver;
|
|
||||||
using BotSharp.Plugin.WebDriver.Hooks;
|
using BotSharp.Plugin.WebDriver.Hooks;
|
||||||
|
|
||||||
namespace BotSharp.Plugin.Playwrights;
|
namespace BotSharp.Plugin.Playwrights;
|
||||||
|
|
@ -12,7 +11,7 @@ public class WebDriverPlugin : IBotSharpPlugin
|
||||||
public string Name => "Web Driver";
|
public string Name => "Web Driver";
|
||||||
public string Description => "Empower agent to manipulate web browser in automation tools.";
|
public string Description => "Empower agent to manipulate web browser in automation tools.";
|
||||||
public string IconUrl => "https://cdn-icons-png.flaticon.com/512/8576/8576378.png";
|
public string IconUrl => "https://cdn-icons-png.flaticon.com/512/8576/8576378.png";
|
||||||
public string[] AgentIds => new[] { "f3ae2a0f-e6ba-4ee1-a0b9-75d7431ff32b" };
|
public string[] AgentIds => ["f3ae2a0f-e6ba-4ee1-a0b9-75d7431ff32b"];
|
||||||
|
|
||||||
public void RegisterDI(IServiceCollection services, IConfiguration config)
|
public void RegisterDI(IServiceCollection services, IConfiguration config)
|
||||||
{
|
{
|
||||||
|
|
@ -28,13 +27,13 @@ public class WebDriverPlugin : IBotSharpPlugin
|
||||||
services.AddScoped<PlaywrightWebDriver>();
|
services.AddScoped<PlaywrightWebDriver>();
|
||||||
services.AddSingleton<PlaywrightInstance>();
|
services.AddSingleton<PlaywrightInstance>();
|
||||||
|
|
||||||
services.AddScoped<SeleniumWebDriver>();
|
// services.AddScoped<SeleniumWebDriver>();
|
||||||
services.AddSingleton<SeleniumInstance>();
|
// services.AddSingleton<SeleniumInstance>();
|
||||||
|
|
||||||
services.AddScoped<IWebBrowser>(provider => settings.Driver switch
|
services.AddScoped<IWebBrowser>(provider => settings.Driver switch
|
||||||
{
|
{
|
||||||
"Playwright" => provider.GetRequiredService<PlaywrightWebDriver>(),
|
"Playwright" => provider.GetRequiredService<PlaywrightWebDriver>(),
|
||||||
"Selenium" => provider.GetRequiredService<SeleniumWebDriver>(),
|
// "Selenium" => provider.GetRequiredService<SeleniumWebDriver>(),
|
||||||
_ => provider.GetRequiredService<PlaywrightWebDriver>(),
|
_ => provider.GetRequiredService<PlaywrightWebDriver>(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue