Streamrole is smarter, but possibly more expensive. It will rescan users when settings are changed. And when the bot is started.
This commit is contained in:
parent
d074444c26
commit
b9bb72f06d
@ -42,7 +42,7 @@ namespace NadekoBot.Modules.Gambling
|
|||||||
{
|
{
|
||||||
role = role ?? Context.Guild.EveryoneRole;
|
role = role ?? Context.Guild.EveryoneRole;
|
||||||
|
|
||||||
var members = role.Members().Where(u => u.Status != UserStatus.Offline);
|
var members = (await role.GetMembersAsync()).Where(u => u.Status != UserStatus.Offline);
|
||||||
var membersArray = members as IUser[] ?? members.ToArray();
|
var membersArray = members as IUser[] ?? members.ToArray();
|
||||||
if (membersArray.Length == 0)
|
if (membersArray.Length == 0)
|
||||||
{
|
{
|
||||||
|
@ -21,8 +21,6 @@ namespace NadekoBot.Modules.Utility.Services
|
|||||||
{
|
{
|
||||||
private readonly DbService _db;
|
private readonly DbService _db;
|
||||||
private readonly ConcurrentDictionary<ulong, StreamRoleSettings> guildSettings;
|
private readonly ConcurrentDictionary<ulong, StreamRoleSettings> guildSettings;
|
||||||
//(guildId, userId), roleId
|
|
||||||
private readonly ConcurrentDictionary<(ulong GuildId, ulong UserId), ulong> toRemove = new ConcurrentDictionary<(ulong GuildId, ulong UserId), ulong>();
|
|
||||||
private readonly Logger _log;
|
private readonly Logger _log;
|
||||||
|
|
||||||
public StreamRoleService(DiscordSocketClient client, DbService db, IEnumerable<GuildConfig> gcs)
|
public StreamRoleService(DiscordSocketClient client, DbService db, IEnumerable<GuildConfig> gcs)
|
||||||
@ -35,6 +33,18 @@ namespace NadekoBot.Modules.Utility.Services
|
|||||||
.ToConcurrent();
|
.ToConcurrent();
|
||||||
|
|
||||||
client.GuildMemberUpdated += Client_GuildMemberUpdated;
|
client.GuildMemberUpdated += Client_GuildMemberUpdated;
|
||||||
|
|
||||||
|
var _ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.WhenAll(client.Guilds.Select(g => RescanUsers(g))).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// ignored
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private Task Client_GuildMemberUpdated(SocketGuildUser before, SocketGuildUser after)
|
private Task Client_GuildMemberUpdated(SocketGuildUser before, SocketGuildUser after)
|
||||||
@ -42,112 +52,30 @@ namespace NadekoBot.Modules.Utility.Services
|
|||||||
var _ = Task.Run(async () =>
|
var _ = Task.Run(async () =>
|
||||||
{
|
{
|
||||||
//if user wasn't streaming or didn't have a game status at all
|
//if user wasn't streaming or didn't have a game status at all
|
||||||
if ((!before.Game.HasValue || before.Game.Value.StreamType == StreamType.NotStreaming)
|
if (guildSettings.TryGetValue(after.Guild.Id, out var setting))
|
||||||
&& guildSettings.TryGetValue(after.Guild.Id, out var setting))
|
|
||||||
{
|
{
|
||||||
await TryApplyRole(after, setting).ConfigureAwait(false);
|
await RescanUser(after, setting).ConfigureAwait(false);
|
||||||
}
|
|
||||||
|
|
||||||
// try removing a role that was given to the user
|
|
||||||
// if user had a game status
|
|
||||||
// and he was streaming
|
|
||||||
// and he no longer has a game status, or has a game status which is not a stream
|
|
||||||
// and if he's scheduled for role removal, get the roleid to remove
|
|
||||||
else if (before.Game.HasValue &&
|
|
||||||
before.Game.Value.StreamType != StreamType.NotStreaming &&
|
|
||||||
(!after.Game.HasValue || after.Game.Value.StreamType == StreamType.NotStreaming) &&
|
|
||||||
toRemove.TryRemove((after.Guild.Id, after.Id), out var roleId))
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
//get the role to remove from the role id
|
|
||||||
var role = after.Guild.GetRole(roleId);
|
|
||||||
if (role == null)
|
|
||||||
return;
|
|
||||||
//check if user has the role which needs to be removed to avoid errors
|
|
||||||
if (after.Roles.Contains(role))
|
|
||||||
await after.RemoveRoleAsync(role).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_log.Warn("Failed removing the stream role from the user who stopped streaming.");
|
|
||||||
_log.Error(ex);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task TryApplyRole(IGuildUser user, StreamRoleSettings setting)
|
|
||||||
{
|
|
||||||
// if the user has a game status now
|
|
||||||
// and that status is a streaming status
|
|
||||||
// and the feature is enabled
|
|
||||||
// and he's not blacklisted
|
|
||||||
// and keyword is either not set, or the game contains the keyword required, or he's whitelisted
|
|
||||||
if (user.Game.HasValue &&
|
|
||||||
user.Game.Value.StreamType != StreamType.NotStreaming
|
|
||||||
&& setting.Enabled
|
|
||||||
&& !setting.Blacklist.Any(x => x.UserId == user.Id)
|
|
||||||
&& (string.IsNullOrWhiteSpace(setting.Keyword)
|
|
||||||
|| user.Game.Value.Name.Contains(setting.Keyword)
|
|
||||||
|| setting.Whitelist.Any(x => x.UserId == user.Id)))
|
|
||||||
{
|
|
||||||
IRole fromRole;
|
|
||||||
IRole addRole;
|
|
||||||
|
|
||||||
//get needed roles
|
|
||||||
fromRole = user.Guild.GetRole(setting.FromRoleId);
|
|
||||||
if (fromRole == null)
|
|
||||||
throw new StreamRoleNotFoundException();
|
|
||||||
addRole = user.Guild.GetRole(setting.AddRoleId);
|
|
||||||
if (addRole == null)
|
|
||||||
throw new StreamRoleNotFoundException();
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
//check if user is in the fromrole
|
|
||||||
if (user.RoleIds.Contains(setting.FromRoleId))
|
|
||||||
{
|
|
||||||
//check if he doesn't have addrole already, to avoid errors
|
|
||||||
if (!user.RoleIds.Contains(setting.AddRoleId))
|
|
||||||
await user.AddRoleAsync(addRole).ConfigureAwait(false);
|
|
||||||
//schedule him for the role removal when he stops streaming
|
|
||||||
toRemove.TryAdd((addRole.Guild.Id, user.Id), addRole.Id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (HttpException ex) when (ex.HttpCode == System.Net.HttpStatusCode.Forbidden)
|
|
||||||
{
|
|
||||||
StopStreamRole(user.Guild.Id);
|
|
||||||
_log.Warn("Error adding stream role(s). Disabling stream role feature.");
|
|
||||||
_log.Error(ex);
|
|
||||||
throw new StreamRolePermissionException();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_log.Warn("Failed adding stream role.");
|
|
||||||
_log.Error(ex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Adds or removes a user from a blacklist or a whitelist in the specified guild.
|
/// Adds or removes a user from a blacklist or a whitelist in the specified guild.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="guildId">Id of the guild</param>
|
/// <param name="guild">Guild</param>
|
||||||
/// <param name="action">Add or rem action</param>
|
/// <param name="action">Add or rem action</param>
|
||||||
/// <param name="userId">User's Id</param>
|
/// <param name="userId">User's Id</param>
|
||||||
/// <param name="userName">User's name#discrim</param>
|
/// <param name="userName">User's name#discrim</param>
|
||||||
/// <returns>Whether the operation was successful</returns>
|
/// <returns>Whether the operation was successful</returns>
|
||||||
public async Task<bool> ApplyListAction(StreamRoleListType listType, ulong guildId, AddRemove action, ulong userId, string userName)
|
public async Task<bool> ApplyListAction(StreamRoleListType listType, IGuild guild, AddRemove action, ulong userId, string userName)
|
||||||
{
|
{
|
||||||
userName.ThrowIfNull(nameof(userName));
|
userName.ThrowIfNull(nameof(userName));
|
||||||
|
|
||||||
bool success;
|
bool success;
|
||||||
using (var uow = _db.UnitOfWork)
|
using (var uow = _db.UnitOfWork)
|
||||||
{
|
{
|
||||||
var streamRoleSettings = uow.GuildConfigs.GetStreamRoleSettings(guildId);
|
var streamRoleSettings = uow.GuildConfigs.GetStreamRoleSettings(guild.Id);
|
||||||
|
|
||||||
if (listType == StreamRoleListType.Whitelist)
|
if (listType == StreamRoleListType.Whitelist)
|
||||||
{
|
{
|
||||||
@ -178,30 +106,34 @@ namespace NadekoBot.Modules.Utility.Services
|
|||||||
|
|
||||||
await uow.CompleteAsync().ConfigureAwait(false);
|
await uow.CompleteAsync().ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
if (success)
|
||||||
|
{
|
||||||
|
await RescanUsers(guild).ConfigureAwait(false);
|
||||||
|
}
|
||||||
return success;
|
return success;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Sets keyword on a guild and updates the cache.
|
/// Sets keyword on a guild and updates the cache.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="guildId">Guild Id</param>
|
/// <param name="guild">Guild Id</param>
|
||||||
/// <param name="keyword">Keyword to set</param>
|
/// <param name="keyword">Keyword to set</param>
|
||||||
/// <returns>The keyword set</returns>
|
/// <returns>The keyword set</returns>
|
||||||
public string SetKeyword(ulong guildId, string keyword)
|
public async Task<string> SetKeyword(IGuild guild, string keyword)
|
||||||
{
|
{
|
||||||
keyword = keyword?.Trim()?.ToLowerInvariant();
|
keyword = keyword?.Trim()?.ToLowerInvariant();
|
||||||
|
|
||||||
using (var uow = _db.UnitOfWork)
|
using (var uow = _db.UnitOfWork)
|
||||||
{
|
{
|
||||||
var streamRoleSettings = uow.GuildConfigs.GetStreamRoleSettings(guildId);
|
var streamRoleSettings = uow.GuildConfigs.GetStreamRoleSettings(guild.Id);
|
||||||
|
|
||||||
streamRoleSettings.Keyword = keyword;
|
streamRoleSettings.Keyword = keyword;
|
||||||
UpdateCache(guildId, streamRoleSettings);
|
UpdateCache(guild.Id, streamRoleSettings);
|
||||||
uow.Complete();
|
uow.Complete();
|
||||||
|
|
||||||
return streamRoleSettings.Keyword;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await RescanUsers(guild).ConfigureAwait(false);
|
||||||
|
return keyword;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -251,9 +183,10 @@ namespace NadekoBot.Modules.Utility.Services
|
|||||||
|
|
||||||
UpdateCache(fromRole.Guild.Id, setting);
|
UpdateCache(fromRole.Guild.Id, setting);
|
||||||
|
|
||||||
foreach (var usr in await fromRole.Guild.GetUsersAsync(CacheMode.CacheOnly).ConfigureAwait(false))
|
foreach (var usr in await fromRole.GetMembersAsync())
|
||||||
{
|
{
|
||||||
await Task.WhenAll(TryApplyRole(usr, setting), Task.Delay(100)).ConfigureAwait(false);
|
if (usr is IGuildUser x)
|
||||||
|
await RescanUser(x, setting, addRole).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -261,16 +194,94 @@ namespace NadekoBot.Modules.Utility.Services
|
|||||||
/// Stops the stream role feature on the specified guild.
|
/// Stops the stream role feature on the specified guild.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="guildId">Guild's Id</param>
|
/// <param name="guildId">Guild's Id</param>
|
||||||
public void StopStreamRole(ulong guildId)
|
public async Task StopStreamRole(IGuild guild, bool cleanup = false)
|
||||||
{
|
{
|
||||||
using (var uow = _db.UnitOfWork)
|
using (var uow = _db.UnitOfWork)
|
||||||
{
|
{
|
||||||
var streamRoleSettings = uow.GuildConfigs.GetStreamRoleSettings(guildId);
|
var streamRoleSettings = uow.GuildConfigs.GetStreamRoleSettings(guild.Id);
|
||||||
streamRoleSettings.Enabled = false;
|
streamRoleSettings.Enabled = false;
|
||||||
uow.Complete();
|
await uow.CompleteAsync().ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
guildSettings.TryRemove(guildId, out _);
|
if (guildSettings.TryRemove(guild.Id, out var setting) && cleanup)
|
||||||
|
await RescanUsers(guild).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
//todo multiple rescans at the same time?
|
||||||
|
private async Task RescanUser(IGuildUser user, StreamRoleSettings setting, IRole addRole = null)
|
||||||
|
{
|
||||||
|
if (user.Game.HasValue &&
|
||||||
|
user.Game.Value.StreamType != StreamType.NotStreaming
|
||||||
|
&& setting.Enabled
|
||||||
|
&& !setting.Blacklist.Any(x => x.UserId == user.Id)
|
||||||
|
&& user.RoleIds.Contains(setting.FromRoleId)
|
||||||
|
&& (string.IsNullOrWhiteSpace(setting.Keyword)
|
||||||
|
|| user.Game.Value.Name.Contains(setting.Keyword)
|
||||||
|
|| setting.Whitelist.Any(x => x.UserId == user.Id)))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
//check if he doesn't have addrole already, to avoid errors
|
||||||
|
if (!user.RoleIds.Contains(setting.AddRoleId))
|
||||||
|
await user.AddRoleAsync(addRole).ConfigureAwait(false);
|
||||||
|
_log.Info("Added stream role to user {0} in {1} server", user.ToString(), user.Guild.ToString());
|
||||||
|
}
|
||||||
|
catch (HttpException ex) when (ex.HttpCode == System.Net.HttpStatusCode.Forbidden)
|
||||||
|
{
|
||||||
|
await StopStreamRole(user.Guild).ConfigureAwait(false);
|
||||||
|
_log.Warn("Error adding stream role(s). Forcibly disabling stream role feature.");
|
||||||
|
_log.Error(ex);
|
||||||
|
throw new StreamRolePermissionException();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_log.Warn("Failed adding stream role.");
|
||||||
|
_log.Error(ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
//check if user is in the addrole
|
||||||
|
if (user.RoleIds.Contains(setting.AddRoleId))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
addRole = addRole ?? user.Guild.GetRole(setting.AddRoleId);
|
||||||
|
if (addRole == null)
|
||||||
|
throw new StreamRoleNotFoundException();
|
||||||
|
|
||||||
|
await user.RemoveRoleAsync(addRole).ConfigureAwait(false);
|
||||||
|
_log.Info("Removed stream role from a user {0} in {1} server", user.ToString(), user.Guild.ToString());
|
||||||
|
}
|
||||||
|
catch (HttpException ex) when (ex.HttpCode == System.Net.HttpStatusCode.Forbidden)
|
||||||
|
{
|
||||||
|
await StopStreamRole(user.Guild).ConfigureAwait(false);
|
||||||
|
_log.Warn("Error removing stream role(s). Forcibly disabling stream role feature.");
|
||||||
|
_log.Error(ex);
|
||||||
|
throw new StreamRolePermissionException();
|
||||||
|
}
|
||||||
|
_log.Info("Removed stream role from the user {0} in {1} server", user.ToString(), user.Guild.ToString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RescanUsers(IGuild guild)
|
||||||
|
{
|
||||||
|
if (!guildSettings.TryGetValue(guild.Id, out var setting))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var addRole = guild.GetRole(setting.AddRoleId);
|
||||||
|
if (addRole == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (setting.Enabled)
|
||||||
|
{
|
||||||
|
var users = await guild.GetUsersAsync(CacheMode.CacheOnly).ConfigureAwait(false);
|
||||||
|
foreach (var usr in users.Where(x => x.RoleIds.Contains(setting.FromRoleId) || x.RoleIds.Contains(addRole.Id)))
|
||||||
|
{
|
||||||
|
if(usr is IGuildUser x)
|
||||||
|
await RescanUser(x, setting, addRole).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateCache(ulong guildId, StreamRoleSettings setting)
|
private void UpdateCache(ulong guildId, StreamRoleSettings setting)
|
||||||
|
@ -5,11 +5,13 @@ using NadekoBot.Common.Attributes;
|
|||||||
using NadekoBot.Modules.Utility.Services;
|
using NadekoBot.Modules.Utility.Services;
|
||||||
using NadekoBot.Common.TypeReaders;
|
using NadekoBot.Common.TypeReaders;
|
||||||
using NadekoBot.Modules.Utility.Common;
|
using NadekoBot.Modules.Utility.Common;
|
||||||
|
using NadekoBot.Common;
|
||||||
|
|
||||||
namespace NadekoBot.Modules.Utility
|
namespace NadekoBot.Modules.Utility
|
||||||
{
|
{
|
||||||
public partial class Utility
|
public partial class Utility
|
||||||
{
|
{
|
||||||
|
[NoPublicBot]
|
||||||
public class StreamRoleCommands : NadekoSubmodule<StreamRoleService>
|
public class StreamRoleCommands : NadekoSubmodule<StreamRoleService>
|
||||||
{
|
{
|
||||||
[NadekoCommand, Usage, Description, Aliases]
|
[NadekoCommand, Usage, Description, Aliases]
|
||||||
@ -29,7 +31,7 @@ namespace NadekoBot.Modules.Utility
|
|||||||
[RequireContext(ContextType.Guild)]
|
[RequireContext(ContextType.Guild)]
|
||||||
public async Task StreamRole()
|
public async Task StreamRole()
|
||||||
{
|
{
|
||||||
this._service.StopStreamRole(Context.Guild.Id);
|
await this._service.StopStreamRole(Context.Guild).ConfigureAwait(false);
|
||||||
await ReplyConfirmLocalized("stream_role_disabled").ConfigureAwait(false);
|
await ReplyConfirmLocalized("stream_role_disabled").ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -39,7 +41,7 @@ namespace NadekoBot.Modules.Utility
|
|||||||
[RequireContext(ContextType.Guild)]
|
[RequireContext(ContextType.Guild)]
|
||||||
public async Task StreamRoleKeyword([Remainder]string keyword = null)
|
public async Task StreamRoleKeyword([Remainder]string keyword = null)
|
||||||
{
|
{
|
||||||
string kw = this._service.SetKeyword(Context.Guild.Id, keyword);
|
string kw = await this._service.SetKeyword(Context.Guild, keyword).ConfigureAwait(false);
|
||||||
|
|
||||||
if(string.IsNullOrWhiteSpace(keyword))
|
if(string.IsNullOrWhiteSpace(keyword))
|
||||||
await ReplyConfirmLocalized("stream_role_kw_reset").ConfigureAwait(false);
|
await ReplyConfirmLocalized("stream_role_kw_reset").ConfigureAwait(false);
|
||||||
@ -53,7 +55,7 @@ namespace NadekoBot.Modules.Utility
|
|||||||
[RequireContext(ContextType.Guild)]
|
[RequireContext(ContextType.Guild)]
|
||||||
public async Task StreamRoleBlacklist(AddRemove action, [Remainder] IGuildUser user)
|
public async Task StreamRoleBlacklist(AddRemove action, [Remainder] IGuildUser user)
|
||||||
{
|
{
|
||||||
var success = await this._service.ApplyListAction(StreamRoleListType.Blacklist, Context.Guild.Id, action, user.Id, user.ToString())
|
var success = await this._service.ApplyListAction(StreamRoleListType.Blacklist, Context.Guild, action, user.Id, user.ToString())
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
if(action == AddRemove.Add)
|
if(action == AddRemove.Add)
|
||||||
@ -74,7 +76,7 @@ namespace NadekoBot.Modules.Utility
|
|||||||
[RequireContext(ContextType.Guild)]
|
[RequireContext(ContextType.Guild)]
|
||||||
public async Task StreamRoleWhitelist(AddRemove action, [Remainder] IGuildUser user)
|
public async Task StreamRoleWhitelist(AddRemove action, [Remainder] IGuildUser user)
|
||||||
{
|
{
|
||||||
var success = await this._service.ApplyListAction(StreamRoleListType.Whitelist, Context.Guild.Id, action, user.Id, user.ToString())
|
var success = await this._service.ApplyListAction(StreamRoleListType.Whitelist, Context.Guild, action, user.Id, user.ToString())
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
if (action == AddRemove.Add)
|
if (action == AddRemove.Add)
|
||||||
|
@ -126,8 +126,8 @@ namespace NadekoBot.Extensions
|
|||||||
|
|
||||||
public static double UnixTimestamp(this DateTime dt) => dt.ToUniversalTime().Subtract(new DateTime(1970, 1, 1, 0, 0, 0)).TotalSeconds;
|
public static double UnixTimestamp(this DateTime dt) => dt.ToUniversalTime().Subtract(new DateTime(1970, 1, 1, 0, 0, 0)).TotalSeconds;
|
||||||
|
|
||||||
public static IEnumerable<IUser> Members(this IRole role) =>
|
public static async Task<IEnumerable<IGuildUser>> GetMembersAsync(this IRole role) =>
|
||||||
role.Guild.GetUsersAsync().GetAwaiter().GetResult().Where(u => u.RoleIds.Contains(role.Id)) ?? Enumerable.Empty<IUser>();
|
(await role.Guild.GetUsersAsync(CacheMode.CacheOnly)).Where(u => u.RoleIds.Contains(role.Id)) ?? Enumerable.Empty<IGuildUser>();
|
||||||
|
|
||||||
public static string ToJson<T>(this T any, Formatting formatting = Formatting.Indented) =>
|
public static string ToJson<T>(this T any, Formatting formatting = Formatting.Indented) =>
|
||||||
JsonConvert.SerializeObject(any, formatting);
|
JsonConvert.SerializeObject(any, formatting);
|
||||||
|
Loading…
Reference in New Issue
Block a user