mirror of
https://gitlab.com/Kwoth/nadekobot.git
synced 2025-09-10 17:28:27 -04:00
NadekoBot Patronage system, Search commands improvements + fixes
This commit is contained in:
@@ -23,6 +23,8 @@ public class DangerousCommandsService : INService
|
||||
// IsClubAdmin = false,
|
||||
TotalXp = 0
|
||||
});
|
||||
|
||||
await ctx.UserXpStats.DeleteAsync();
|
||||
await ctx.ClubApplicants.DeleteAsync();
|
||||
await ctx.ClubBans.DeleteAsync();
|
||||
await ctx.Clubs.DeleteAsync();
|
||||
|
@@ -23,12 +23,13 @@ public class PruneService : INService
|
||||
|
||||
try
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
IMessage[] msgs;
|
||||
IMessage lastMessage = null;
|
||||
msgs = (await channel.GetMessagesAsync(50).FlattenAsync()).Where(predicate).Take(amount).ToArray();
|
||||
while (amount > 0 && msgs.Any())
|
||||
{
|
||||
lastMessage = msgs[msgs.Length - 1];
|
||||
lastMessage = msgs[^1];
|
||||
|
||||
var bulkDeletable = new List<IMessage>();
|
||||
var singleDeletable = new List<IMessage>();
|
||||
@@ -36,17 +37,23 @@ public class PruneService : INService
|
||||
{
|
||||
_logService.AddDeleteIgnore(x.Id);
|
||||
|
||||
if (DateTime.UtcNow - x.CreatedAt < _twoWeeks)
|
||||
if (now - x.CreatedAt < _twoWeeks)
|
||||
bulkDeletable.Add(x);
|
||||
else
|
||||
singleDeletable.Add(x);
|
||||
}
|
||||
|
||||
if (bulkDeletable.Count > 0)
|
||||
await Task.WhenAll(Task.Delay(1000), channel.DeleteMessagesAsync(bulkDeletable));
|
||||
{
|
||||
await channel.DeleteMessagesAsync(bulkDeletable);
|
||||
await Task.Delay(2000);
|
||||
}
|
||||
|
||||
foreach (var group in singleDeletable.Chunk(5))
|
||||
await Task.WhenAll(Task.Delay(5000), group.Select(x => x.DeleteAsync()).WhenAll());
|
||||
{
|
||||
await group.Select(x => x.DeleteAsync()).WhenAll();
|
||||
await Task.Delay(5000);
|
||||
}
|
||||
|
||||
//this isn't good, because this still work as if i want to remove only specific user's messages from the last
|
||||
//100 messages, Maybe this needs to be reduced by msgs.Length instead of 100
|
||||
|
@@ -1,6 +1,8 @@
|
||||
#nullable disable
|
||||
using NadekoBot.Modules.Utility.Patronage;
|
||||
using NadekoBot.Services.Database.Models;
|
||||
using System.Collections;
|
||||
using OneOf;
|
||||
using OneOf.Types;
|
||||
|
||||
namespace NadekoBot.Modules.Administration.Services;
|
||||
|
||||
@@ -9,18 +11,16 @@ public interface IReactionRoleService
|
||||
/// <summary>
|
||||
/// Adds a single reaction role
|
||||
/// </summary>
|
||||
/// <param name="guildId"></param>
|
||||
/// <param name="msg"></param>
|
||||
/// <param name="channel"></param>
|
||||
/// <param name="guild">Guild where to add a reaction role</param>
|
||||
/// <param name="msg">Message to which to add a reaction role</param>
|
||||
/// <param name="emote"></param>
|
||||
/// <param name="role"></param>
|
||||
/// <param name="group"></param>
|
||||
/// <param name="levelReq"></param>
|
||||
/// <returns></returns>
|
||||
Task<bool> AddReactionRole(
|
||||
ulong guildId,
|
||||
/// <returns>The result of the operation</returns>
|
||||
Task<OneOf<Success, FeatureLimit>> AddReactionRole(
|
||||
IGuild guild,
|
||||
IMessage msg,
|
||||
ITextChannel channel,
|
||||
string emote,
|
||||
IRole role,
|
||||
int group = 0,
|
||||
|
@@ -15,7 +15,6 @@ public partial class Administration
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[NoPublicBot]
|
||||
[UserPerm(GuildPerm.ManageRoles)]
|
||||
[BotPerm(GuildPerm.ManageRoles)]
|
||||
public async partial Task ReactionRoleAdd(
|
||||
@@ -46,27 +45,26 @@ public partial class Administration
|
||||
|
||||
var emote = emoteStr.ToIEmote();
|
||||
await msg.AddReactionAsync(emote);
|
||||
var succ = await _rero.AddReactionRole(ctx.Guild.Id,
|
||||
var res = await _rero.AddReactionRole(ctx.Guild,
|
||||
msg,
|
||||
(ITextChannel)ctx.Channel,
|
||||
emoteStr,
|
||||
role,
|
||||
group,
|
||||
levelReq);
|
||||
|
||||
if (succ)
|
||||
{
|
||||
await ctx.OkAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
await ctx.ErrorAsync();
|
||||
}
|
||||
|
||||
await res.Match(
|
||||
_ => ctx.OkAsync(),
|
||||
fl =>
|
||||
{
|
||||
_ = msg.RemoveReactionAsync(emote, ctx.Client.CurrentUser);
|
||||
return !fl.IsPatronLimit
|
||||
? ReplyErrorLocalizedAsync(strs.limit_reached(fl.Quota))
|
||||
: ReplyPendingLocalizedAsync(strs.feature_limit_reached_owner(fl.Quota, fl.Name));
|
||||
});
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[NoPublicBot]
|
||||
[UserPerm(GuildPerm.ManageRoles)]
|
||||
[BotPerm(GuildPerm.ManageRoles)]
|
||||
public async partial Task ReactionRolesList()
|
||||
@@ -109,7 +107,6 @@ public partial class Administration
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[NoPublicBot]
|
||||
[UserPerm(GuildPerm.ManageRoles)]
|
||||
[BotPerm(GuildPerm.ManageRoles)]
|
||||
public async partial Task ReactionRolesRemove(ulong messageId)
|
||||
@@ -123,7 +120,6 @@ public partial class Administration
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[NoPublicBot]
|
||||
[UserPerm(GuildPerm.ManageRoles)]
|
||||
[BotPerm(GuildPerm.ManageRoles)]
|
||||
public async partial Task ReactionRolesDeleteAll()
|
||||
@@ -134,7 +130,6 @@ public partial class Administration
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[NoPublicBot]
|
||||
[UserPerm(GuildPerm.ManageRoles)]
|
||||
[BotPerm(GuildPerm.ManageRoles)]
|
||||
[Ratelimit(60)]
|
||||
|
@@ -2,8 +2,11 @@
|
||||
using LinqToDB;
|
||||
using LinqToDB.EntityFrameworkCore;
|
||||
using NadekoBot.Common.ModuleBehaviors;
|
||||
using NadekoBot.Modules.Utility.Patronage;
|
||||
using NadekoBot.Modules.Xp.Extensions;
|
||||
using NadekoBot.Services.Database.Models;
|
||||
using OneOf.Types;
|
||||
using OneOf;
|
||||
|
||||
namespace NadekoBot.Modules.Administration.Services;
|
||||
|
||||
@@ -16,20 +19,33 @@ public sealed class ReactionRolesService : IReadyExecutor, INService, IReactionR
|
||||
private ConcurrentDictionary<ulong, List<ReactionRoleV2>> _cache;
|
||||
private readonly object _cacheLock = new();
|
||||
private readonly SemaphoreSlim _assignementLock = new(1, 1);
|
||||
private readonly IPatronageService _ps;
|
||||
|
||||
public ReactionRolesService(DiscordSocketClient client, DbService db, IBotCredentials creds)
|
||||
private static readonly FeatureLimitKey _reroFLKey = new()
|
||||
{
|
||||
Key = "rero:max_count",
|
||||
PrettyName = "Reaction Role"
|
||||
};
|
||||
|
||||
public ReactionRolesService(
|
||||
DiscordSocketClient client,
|
||||
DbService db,
|
||||
IBotCredentials creds,
|
||||
IPatronageService ps)
|
||||
{
|
||||
_db = db;
|
||||
_ps = ps;
|
||||
_client = client;
|
||||
_creds = creds;
|
||||
_cache = new();
|
||||
}
|
||||
|
||||
|
||||
public async Task OnReadyAsync()
|
||||
{
|
||||
await using var uow = _db.GetDbContext();
|
||||
var reros = await uow.GetTable<ReactionRoleV2>()
|
||||
.Where(x => Linq2DbExpressions.GuildOnShard(x.GuildId, _creds.TotalShards, _client.ShardId))
|
||||
.Where(
|
||||
x => Linq2DbExpressions.GuildOnShard(x.GuildId, _creds.TotalShards, _client.ShardId))
|
||||
.ToListAsyncLinqToDB();
|
||||
|
||||
foreach (var group in reros.GroupBy(x => x.MessageId))
|
||||
@@ -126,12 +142,12 @@ public sealed class ReactionRolesService : IReadyExecutor, INService, IReactionR
|
||||
{
|
||||
await using var ctx = _db.GetDbContext();
|
||||
var levelData = await ctx.GetTable<UserXpStats>()
|
||||
.GetLevelDataFor(user.GuildId, user.Id);
|
||||
.GetLevelDataFor(user.GuildId, user.Id);
|
||||
|
||||
if (levelData.Level < rero.LevelReq)
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// remove all other roles from the same group from the user
|
||||
// execept in group 0, which is a special, non-exclusive group
|
||||
if (rero.Group != 0)
|
||||
@@ -141,7 +157,7 @@ public sealed class ReactionRolesService : IReadyExecutor, INService, IReactionR
|
||||
.Select(x => x.RoleId)
|
||||
.Distinct();
|
||||
|
||||
|
||||
|
||||
try { await user.RemoveRolesAsync(exclusive); }
|
||||
catch { }
|
||||
|
||||
@@ -181,18 +197,16 @@ public sealed class ReactionRolesService : IReadyExecutor, INService, IReactionR
|
||||
/// <summary>
|
||||
/// Adds a single reaction role
|
||||
/// </summary>
|
||||
/// <param name="guildId"></param>
|
||||
/// <param name="msg"></param>
|
||||
/// <param name="channel"></param>
|
||||
/// <param name="guild">Guild where to add a reaction role</param>
|
||||
/// <param name="msg">Message to which to add a reaction role</param>
|
||||
/// <param name="emote"></param>
|
||||
/// <param name="role"></param>
|
||||
/// <param name="group"></param>
|
||||
/// <param name="levelReq"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<bool> AddReactionRole(
|
||||
ulong guildId,
|
||||
/// <returns>The result of the operation</returns>
|
||||
public async Task<OneOf<Success, FeatureLimit>> AddReactionRole(
|
||||
IGuild guild,
|
||||
IMessage msg,
|
||||
ITextChannel channel,
|
||||
string emote,
|
||||
IRole role,
|
||||
int group = 0,
|
||||
@@ -205,44 +219,46 @@ public sealed class ReactionRolesService : IReadyExecutor, INService, IReactionR
|
||||
throw new ArgumentOutOfRangeException(nameof(group));
|
||||
|
||||
await using var ctx = _db.GetDbContext();
|
||||
|
||||
await using var tran = await ctx.Database.BeginTransactionAsync();
|
||||
var activeReactionRoles = await ctx.GetTable<ReactionRoleV2>()
|
||||
.Where(x => x.GuildId == guildId)
|
||||
.Where(x => x.GuildId == guild.Id)
|
||||
.CountAsync();
|
||||
|
||||
var result = await _ps.TryGetFeatureLimitAsync(_reroFLKey, guild.OwnerId, 50);
|
||||
if (result.Quota != -1 && activeReactionRoles >= result.Quota)
|
||||
return result;
|
||||
|
||||
if (activeReactionRoles >= 50)
|
||||
return false;
|
||||
await ctx.GetTable<ReactionRoleV2>()
|
||||
.InsertOrUpdateAsync(() => new()
|
||||
{
|
||||
GuildId = guild.Id,
|
||||
ChannelId = msg.Channel.Id,
|
||||
|
||||
var changed = await ctx.GetTable<ReactionRoleV2>()
|
||||
.InsertOrUpdateAsync(() => new()
|
||||
{
|
||||
GuildId = guildId,
|
||||
ChannelId = channel.Id,
|
||||
MessageId = msg.Id,
|
||||
Emote = emote,
|
||||
|
||||
MessageId = msg.Id,
|
||||
Emote = emote,
|
||||
RoleId = role.Id,
|
||||
Group = group,
|
||||
LevelReq = levelReq
|
||||
},
|
||||
(old) => new()
|
||||
{
|
||||
RoleId = role.Id,
|
||||
Group = group,
|
||||
LevelReq = levelReq
|
||||
},
|
||||
() => new()
|
||||
{
|
||||
MessageId = msg.Id,
|
||||
Emote = emote,
|
||||
});
|
||||
|
||||
RoleId = role.Id,
|
||||
Group = group,
|
||||
LevelReq = levelReq
|
||||
},
|
||||
(old) => new()
|
||||
{
|
||||
RoleId = role.Id,
|
||||
Group = group,
|
||||
LevelReq = levelReq
|
||||
},
|
||||
() => new()
|
||||
{
|
||||
MessageId = msg.Id,
|
||||
Emote = emote,
|
||||
});
|
||||
|
||||
if (changed == 0)
|
||||
return false;
|
||||
await tran.CommitAsync();
|
||||
|
||||
var obj = new ReactionRoleV2()
|
||||
{
|
||||
GuildId = guildId,
|
||||
GuildId = guild.Id,
|
||||
MessageId = msg.Id,
|
||||
Emote = emote,
|
||||
RoleId = role.Id,
|
||||
@@ -265,7 +281,7 @@ public sealed class ReactionRolesService : IReadyExecutor, INService, IReactionR
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
return new Success();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -326,7 +342,10 @@ public sealed class ReactionRolesService : IReadyExecutor, INService, IReactionR
|
||||
return output.Length;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyCollection<IEmote>> TransferReactionRolesAsync(ulong guildId, ulong fromMessageId, ulong toMessageId)
|
||||
public async Task<IReadOnlyCollection<IEmote>> TransferReactionRolesAsync(
|
||||
ulong guildId,
|
||||
ulong fromMessageId,
|
||||
ulong toMessageId)
|
||||
{
|
||||
await using var ctx = _db.GetDbContext();
|
||||
var updated = ctx.GetTable<ReactionRoleV2>()
|
||||
|
@@ -149,7 +149,7 @@ public partial class Administration
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.ManageRoles)]
|
||||
[BotPerm(GuildPerm.ManageRoles)]
|
||||
public async partial Task RoleHoist(IRole role)
|
||||
public async partial Task RoleHoist([Leftover] IRole role)
|
||||
{
|
||||
var newHoisted = !role.IsHoisted;
|
||||
await role.ModifyAsync(r => r.Hoist = newHoisted);
|
||||
|
@@ -65,7 +65,7 @@ public partial class Gambling
|
||||
}
|
||||
catch
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.unable_to_dm_user);
|
||||
await ReplyErrorLocalizedAsync(strs.cant_dm);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,5 +1,6 @@
|
||||
using LinqToDB;
|
||||
using LinqToDB.EntityFrameworkCore;
|
||||
using NadekoBot.Db.Models;
|
||||
|
||||
namespace NadekoBot.Modules.Gambling.Bank;
|
||||
|
||||
@@ -74,4 +75,19 @@ public sealed class BankService : IBankService, INService
|
||||
?.Balance
|
||||
?? 0;
|
||||
}
|
||||
|
||||
public async Task<long> BurnAllAsync(ulong userId)
|
||||
{
|
||||
await using var ctx = _db.GetDbContext();
|
||||
var output = await ctx.GetTable<BankUser>()
|
||||
.Where(x => x.UserId == userId)
|
||||
.UpdateWithOutputAsync(old => new()
|
||||
{
|
||||
Balance = 0
|
||||
});
|
||||
if (output.Length == 0)
|
||||
return 0;
|
||||
|
||||
return output[0].Deleted.Balance;
|
||||
}
|
||||
}
|
@@ -5,4 +5,5 @@ public interface IBankService
|
||||
Task<bool> DepositAsync(ulong userId, long amount);
|
||||
Task<bool> WithdrawAsync(ulong userId, long amount);
|
||||
Task<long> GetBalanceAsync(ulong userId);
|
||||
Task<long> BurnAllAsync(ulong userId);
|
||||
}
|
@@ -1,17 +1,13 @@
|
||||
#nullable disable
|
||||
namespace NadekoBot.Modules.Gambling;
|
||||
|
||||
public class CashInteraction
|
||||
public class CashInteraction : NInteraction
|
||||
{
|
||||
public static NadekoInteractionData Data =
|
||||
new NadekoInteractionData(new Emoji("🏦"), "cash:bank_show_balance");
|
||||
protected override NadekoInteractionData Data
|
||||
=> new NadekoInteractionData(new Emoji("🏦"), "cash:bank_show_balance");
|
||||
|
||||
public static NadekoInteraction CreateInstance(
|
||||
DiscordSocketClient client,
|
||||
ulong userId,
|
||||
Func<SocketMessageComponent, Task> action)
|
||||
=> new NadekoInteractionBuilder()
|
||||
.WithData(Data)
|
||||
.WithAction(action)
|
||||
.Build(client, userId);
|
||||
public CashInteraction(DiscordSocketClient client, ulong userId, Func<SocketMessageComponent, Task> action)
|
||||
: base(client, userId, action)
|
||||
{
|
||||
}
|
||||
}
|
@@ -3,11 +3,13 @@ using LinqToDB;
|
||||
using LinqToDB.EntityFrameworkCore;
|
||||
using NadekoBot.Db;
|
||||
using NadekoBot.Db.Models;
|
||||
using NadekoBot.Modules.Utility.Patronage;
|
||||
using NadekoBot.Modules.Gambling.Bank;
|
||||
using NadekoBot.Modules.Gambling.Common;
|
||||
using NadekoBot.Modules.Gambling.Services;
|
||||
using NadekoBot.Services.Currency;
|
||||
using NadekoBot.Services.Database.Models;
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
@@ -42,6 +44,7 @@ public partial class Gambling : GamblingModule<GamblingService>
|
||||
private readonly DownloadTracker _tracker;
|
||||
private readonly GamblingConfigService _configService;
|
||||
private readonly IBankService _bank;
|
||||
private readonly IPatronageService _ps;
|
||||
|
||||
private IUserMessage rdMsg;
|
||||
|
||||
@@ -52,7 +55,8 @@ public partial class Gambling : GamblingModule<GamblingService>
|
||||
DiscordSocketClient client,
|
||||
DownloadTracker tracker,
|
||||
GamblingConfigService configService,
|
||||
IBankService bank)
|
||||
IBankService bank,
|
||||
IPatronageService ps)
|
||||
: base(configService)
|
||||
{
|
||||
_db = db;
|
||||
@@ -60,6 +64,7 @@ public partial class Gambling : GamblingModule<GamblingService>
|
||||
_cache = cache;
|
||||
_client = client;
|
||||
_bank = bank;
|
||||
_ps = ps;
|
||||
|
||||
_enUsCulture = new CultureInfo("en-US", false).NumberFormat;
|
||||
_enUsCulture.NumberDecimalDigits = 0;
|
||||
@@ -102,6 +107,12 @@ public partial class Gambling : GamblingModule<GamblingService>
|
||||
await ctx.Channel.EmbedAsync(embed);
|
||||
}
|
||||
|
||||
private static readonly FeatureLimitKey _timelyKey = new FeatureLimitKey()
|
||||
{
|
||||
Key = "timely:extra_percent",
|
||||
PrettyName = "Timely"
|
||||
};
|
||||
|
||||
[Cmd]
|
||||
public async partial Task Timely()
|
||||
{
|
||||
@@ -119,6 +130,10 @@ public partial class Gambling : GamblingModule<GamblingService>
|
||||
return;
|
||||
}
|
||||
|
||||
var result = await _ps.TryGetFeatureLimitAsync(_timelyKey, ctx.User.Id, 0);
|
||||
|
||||
val = (int)(val * (1 + (result.Quota * 0.01f)));
|
||||
|
||||
await _cs.AddAsync(ctx.User.Id, val, new("timely", "claim"));
|
||||
|
||||
await ReplyConfirmLocalizedAsync(strs.timely(N(val), period));
|
||||
@@ -331,8 +346,8 @@ public partial class Gambling : GamblingModule<GamblingService>
|
||||
.Pipe(text => smc.RespondConfirmAsync(_eb, text, ephemeral: true));
|
||||
}
|
||||
|
||||
private NadekoInteraction CreateCashInteraction()
|
||||
=> CashInteraction.CreateInstance(_client, ctx.User.Id, BankAction);
|
||||
private NadekoButtonInteraction CreateCashInteraction()
|
||||
=> new CashInteraction(_client, ctx.User.Id, BankAction).GetInteraction();
|
||||
|
||||
[Cmd]
|
||||
[Priority(1)]
|
||||
@@ -780,4 +795,31 @@ public partial class Gambling : GamblingModule<GamblingService>
|
||||
|
||||
await ctx.Channel.EmbedAsync(embed);
|
||||
}
|
||||
|
||||
private static readonly ImmutableArray<string> _emojis =
|
||||
new[] { "⬆", "↖", "⬅", "↙", "⬇", "↘", "➡", "↗" }.ToImmutableArray();
|
||||
|
||||
[Cmd]
|
||||
public async partial Task WheelOfFortune(ShmartNumber amount)
|
||||
{
|
||||
if (!await CheckBetMandatory(amount))
|
||||
return;
|
||||
|
||||
if (!await _cs.RemoveAsync(ctx.User.Id, amount, new("wheel", "bet")))
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.not_enough(CurrencySign));
|
||||
return;
|
||||
}
|
||||
|
||||
var result = await _service.WheelOfFortuneSpinAsync(ctx.User.Id, amount);
|
||||
|
||||
var wofMultipliers = Config.WheelOfFortune.Multipliers;
|
||||
await SendConfirmAsync(Format.Bold($@"{ctx.User} won: {N(result.Amount)}
|
||||
|
||||
『{wofMultipliers[1]}』 『{wofMultipliers[0]}』 『{wofMultipliers[7]}』
|
||||
|
||||
『{wofMultipliers[2]}』 {_emojis[result.Index]} 『{wofMultipliers[6]}』
|
||||
|
||||
『{wofMultipliers[3]}』 『{wofMultipliers[4]}』 『{wofMultipliers[5]}』"));
|
||||
}
|
||||
}
|
@@ -1,49 +0,0 @@
|
||||
#nullable disable
|
||||
using NadekoBot.Modules.Gambling.Common;
|
||||
using NadekoBot.Modules.Gambling.Services;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace NadekoBot.Modules.Gambling;
|
||||
|
||||
public partial class Gambling
|
||||
{
|
||||
public partial class WheelOfFortuneCommands : GamblingSubmodule<GamblingService>
|
||||
{
|
||||
private static readonly ImmutableArray<string> _emojis =
|
||||
new[] { "⬆", "↖", "⬅", "↙", "⬇", "↘", "➡", "↗" }.ToImmutableArray();
|
||||
|
||||
private readonly ICurrencyService _cs;
|
||||
private readonly DbService _db;
|
||||
|
||||
public WheelOfFortuneCommands(ICurrencyService cs, DbService db, GamblingConfigService gamblingConfService)
|
||||
: base(gamblingConfService)
|
||||
{
|
||||
_cs = cs;
|
||||
_db = db;
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
public async partial Task WheelOfFortune(ShmartNumber amount)
|
||||
{
|
||||
if (!await CheckBetMandatory(amount))
|
||||
return;
|
||||
|
||||
if (!await _cs.RemoveAsync(ctx.User.Id, amount, new("wheel", "bet")))
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.not_enough(CurrencySign));
|
||||
return;
|
||||
}
|
||||
|
||||
var result = await _service.WheelOfFortuneSpinAsync(ctx.User.Id, amount);
|
||||
|
||||
var wofMultipliers = Config.WheelOfFortune.Multipliers;
|
||||
await SendConfirmAsync(Format.Bold($@"{ctx.User} won: {N(result.Amount)}
|
||||
|
||||
『{wofMultipliers[1]}』 『{wofMultipliers[0]}』 『{wofMultipliers[7]}』
|
||||
|
||||
『{wofMultipliers[2]}』 {_emojis[result.Index]} 『{wofMultipliers[6]}』
|
||||
|
||||
『{wofMultipliers[3]}』 『{wofMultipliers[4]}』 『{wofMultipliers[5]}』"));
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,8 +1,11 @@
|
||||
#nullable disable
|
||||
using NadekoBot.Common.ModuleBehaviors;
|
||||
using NadekoBot.Db.Models;
|
||||
using NadekoBot.Modules.Games.Common.ChatterBot;
|
||||
using NadekoBot.Modules.Permissions;
|
||||
using NadekoBot.Modules.Permissions.Common;
|
||||
using NadekoBot.Modules.Permissions.Services;
|
||||
using NadekoBot.Modules.Utility.Patronage;
|
||||
|
||||
namespace NadekoBot.Modules.Games.Services;
|
||||
|
||||
@@ -13,6 +16,8 @@ public class ChatterBotService : IExecOnMessage
|
||||
public int Priority
|
||||
=> 1;
|
||||
|
||||
private readonly FeatureLimitKey _flKey;
|
||||
|
||||
private readonly DiscordSocketClient _client;
|
||||
private readonly PermissionService _perms;
|
||||
private readonly CommandHandler _cmd;
|
||||
@@ -20,6 +25,8 @@ public class ChatterBotService : IExecOnMessage
|
||||
private readonly IBotCredentials _creds;
|
||||
private readonly IEmbedBuilderService _eb;
|
||||
private readonly IHttpClientFactory _httpFactory;
|
||||
private readonly IPatronageService _ps;
|
||||
private readonly CmdCdService _ccs;
|
||||
|
||||
public ChatterBotService(
|
||||
DiscordSocketClient client,
|
||||
@@ -29,7 +36,9 @@ public class ChatterBotService : IExecOnMessage
|
||||
IBotStrings strings,
|
||||
IHttpClientFactory factory,
|
||||
IBotCredentials creds,
|
||||
IEmbedBuilderService eb)
|
||||
IEmbedBuilderService eb,
|
||||
IPatronageService ps,
|
||||
CmdCdService cmdCdService)
|
||||
{
|
||||
_client = client;
|
||||
_perms = perms;
|
||||
@@ -38,8 +47,17 @@ public class ChatterBotService : IExecOnMessage
|
||||
_creds = creds;
|
||||
_eb = eb;
|
||||
_httpFactory = factory;
|
||||
_ps = ps;
|
||||
_ccs = cmdCdService;
|
||||
|
||||
ChatterBotGuilds = new(bot.AllGuildConfigs.Where(gc => gc.CleverbotEnabled)
|
||||
_flKey = new FeatureLimitKey()
|
||||
{
|
||||
Key = CleverBotResponseStr.CLEVERBOT_RESPONSE,
|
||||
PrettyName = "Cleverbot Replies"
|
||||
};
|
||||
|
||||
ChatterBotGuilds = new(bot.AllGuildConfigs
|
||||
.Where(gc => gc.CleverbotEnabled)
|
||||
.ToDictionary(gc => gc.GuildId,
|
||||
_ => new Lazy<IChatterBotSession>(() => CreateSession(), true)));
|
||||
}
|
||||
@@ -48,7 +66,9 @@ public class ChatterBotService : IExecOnMessage
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(_creds.CleverbotApiKey))
|
||||
return new OfficialCleverbotSession(_creds.CleverbotApiKey, _httpFactory);
|
||||
return new CleverbotIoSession("GAh3wUfzDCpDpdpT", "RStKgqn7tcO9blbrv4KbXM8NDlb7H37C", _httpFactory);
|
||||
|
||||
Log.Information("Cleverbot will not work as the api key is missing.");
|
||||
return null;
|
||||
}
|
||||
|
||||
public string PrepareMessage(IUserMessage msg, out IChatterBotSession cleverbot)
|
||||
@@ -78,27 +98,11 @@ public class ChatterBotService : IExecOnMessage
|
||||
return message;
|
||||
}
|
||||
|
||||
public async Task<bool> TryAsk(IChatterBotSession cleverbot, ITextChannel channel, string message)
|
||||
{
|
||||
await channel.TriggerTypingAsync();
|
||||
|
||||
var response = await cleverbot.Think(message);
|
||||
try
|
||||
{
|
||||
await channel.SendConfirmAsync(_eb, response.SanitizeMentions(true));
|
||||
}
|
||||
catch
|
||||
{
|
||||
await channel.SendConfirmAsync(_eb, response.SanitizeMentions(true)); // try twice :\
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> ExecOnMessageAsync(IGuild guild, IUserMessage usrMsg)
|
||||
{
|
||||
if (guild is not SocketGuild sg)
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
var message = PrepareMessage(usrMsg, out var cbs);
|
||||
@@ -106,7 +110,10 @@ public class ChatterBotService : IExecOnMessage
|
||||
return false;
|
||||
|
||||
var pc = _perms.GetCacheFor(guild.Id);
|
||||
if (!pc.Permissions.CheckPermissions(usrMsg, "cleverbot", "Games".ToLowerInvariant(), out var index))
|
||||
if (!pc.Permissions.CheckPermissions(usrMsg,
|
||||
"cleverbot",
|
||||
"games",
|
||||
out var index))
|
||||
{
|
||||
if (pc.Verbose)
|
||||
{
|
||||
@@ -122,24 +129,78 @@ public class ChatterBotService : IExecOnMessage
|
||||
return true;
|
||||
}
|
||||
|
||||
var cleverbotExecuted = await TryAsk(cbs, (ITextChannel)usrMsg.Channel, message);
|
||||
if (cleverbotExecuted)
|
||||
if (await _ccs.TryBlock(sg, usrMsg.Author, CleverBotResponseStr.CLEVERBOT_RESPONSE))
|
||||
{
|
||||
Log.Information(@"CleverBot Executed
|
||||
return true;
|
||||
}
|
||||
|
||||
var channel = (ITextChannel)usrMsg.Channel;
|
||||
var conf = _ps.GetConfig();
|
||||
if (conf.IsEnabled)
|
||||
{
|
||||
var quota = await _ps.TryGetFeatureLimitAsync(_flKey, sg.OwnerId, 0);
|
||||
|
||||
uint? daily = quota.Quota is int dVal and < 0
|
||||
? (uint)-dVal
|
||||
: null;
|
||||
|
||||
uint? monthly = quota.Quota is int mVal and >= 0
|
||||
? (uint)mVal
|
||||
: null;
|
||||
|
||||
var maybeLimit = await _ps.TryIncrementQuotaCounterAsync(sg.OwnerId,
|
||||
sg.OwnerId == usrMsg.Author.Id,
|
||||
FeatureType.Limit,
|
||||
_flKey.Key,
|
||||
null,
|
||||
daily,
|
||||
monthly);
|
||||
|
||||
if (maybeLimit.TryPickT1(out var ql, out var counters))
|
||||
{
|
||||
if (ql.Quota == 0)
|
||||
{
|
||||
await channel.SendErrorAsync(_eb,
|
||||
null!,
|
||||
text:
|
||||
"In order to use the cleverbot feature, the owner of this server should be [Patron Tier X](https://patreon.com/join/nadekobot) on patreon.",
|
||||
footer:
|
||||
"You may disable the cleverbot feature, and this message via '.cleverbot' command");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
await channel.SendErrorAsync(_eb,
|
||||
null!,
|
||||
$"You've reached your quota limit of **{ql.Quota}** responses {ql.QuotaPeriod.ToFullName()} for the cleverbot feature.",
|
||||
footer: "You may wait for the quota reset or .");
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
_ = channel.TriggerTypingAsync();
|
||||
var response = await cbs.Think(message);
|
||||
await channel.SendConfirmAsync(_eb,
|
||||
title: null,
|
||||
response.SanitizeMentions(true)
|
||||
// , footer: counter > 0 ? counter.ToString() : null
|
||||
);
|
||||
|
||||
Log.Information(@"CleverBot Executed
|
||||
Server: {GuildName} [{GuildId}]
|
||||
Channel: {ChannelName} [{ChannelId}]
|
||||
UserId: {Author} [{AuthorId}]
|
||||
Message: {Content}",
|
||||
guild.Name,
|
||||
guild.Id,
|
||||
usrMsg.Channel?.Name,
|
||||
usrMsg.Channel?.Id,
|
||||
usrMsg.Author,
|
||||
usrMsg.Author.Id,
|
||||
usrMsg.Content);
|
||||
guild.Name,
|
||||
guild.Id,
|
||||
usrMsg.Channel?.Name,
|
||||
usrMsg.Channel?.Id,
|
||||
usrMsg.Author,
|
||||
usrMsg.Author.Id,
|
||||
usrMsg.Content);
|
||||
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
@@ -14,7 +14,6 @@ public partial class Games
|
||||
public ChatterBotCommands(DbService db)
|
||||
=> _db = db;
|
||||
|
||||
[NoPublicBot]
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.ManageMessages)]
|
||||
|
@@ -1,34 +0,0 @@
|
||||
#nullable disable
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace NadekoBot.Modules.Games.Common.ChatterBot;
|
||||
|
||||
public class ChatterBotSession : IChatterBotSession
|
||||
{
|
||||
private static NadekoRandom Rng { get; } = new();
|
||||
|
||||
private string ApiEndpoint
|
||||
=> "http://api.program-o.com/v2/chatbot/"
|
||||
+ $"?bot_id={_botId}&"
|
||||
+ "say={0}&"
|
||||
+ $"convo_id=nadekobot_{_chatterBotId}&"
|
||||
+ "format=json";
|
||||
|
||||
private readonly string _chatterBotId;
|
||||
private readonly IHttpClientFactory _httpFactory;
|
||||
private readonly int _botId = 6;
|
||||
|
||||
public ChatterBotSession(IHttpClientFactory httpFactory)
|
||||
{
|
||||
_chatterBotId = Rng.Next(0, 1000000).ToString().ToBase64();
|
||||
_httpFactory = httpFactory;
|
||||
}
|
||||
|
||||
public async Task<string> Think(string message)
|
||||
{
|
||||
using var http = _httpFactory.CreateClient();
|
||||
var res = await http.GetStringAsync(string.Format(ApiEndpoint, message));
|
||||
var cbr = JsonConvert.DeserializeObject<ChatterBotResponse>(res);
|
||||
return cbr.BotSay.Replace("<br/>", "\n", StringComparison.InvariantCulture);
|
||||
}
|
||||
}
|
@@ -5,16 +5,4 @@ public class CleverbotResponse
|
||||
{
|
||||
public string Cs { get; set; }
|
||||
public string Output { get; set; }
|
||||
}
|
||||
|
||||
public class CleverbotIoCreateResponse
|
||||
{
|
||||
public string Status { get; set; }
|
||||
public string Nick { get; set; }
|
||||
}
|
||||
|
||||
public class CleverbotIoAskResponse
|
||||
{
|
||||
public string Status { get; set; }
|
||||
public string Response { get; set; }
|
||||
}
|
@@ -35,57 +35,4 @@ public class OfficialCleverbotSession : IChatterBotSession
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class CleverbotIoSession : IChatterBotSession
|
||||
{
|
||||
private readonly string _key;
|
||||
private readonly string _user;
|
||||
private readonly IHttpClientFactory _httpFactory;
|
||||
private readonly AsyncLazy<string> _nick;
|
||||
|
||||
private readonly string _createEndpoint = "https://cleverbot.io/1.0/create";
|
||||
private readonly string _askEndpoint = "https://cleverbot.io/1.0/ask";
|
||||
|
||||
public CleverbotIoSession(string user, string key, IHttpClientFactory factory)
|
||||
{
|
||||
_key = key;
|
||||
_user = user;
|
||||
_httpFactory = factory;
|
||||
|
||||
_nick = new(GetNick);
|
||||
}
|
||||
|
||||
private async Task<string> GetNick()
|
||||
{
|
||||
using var http = _httpFactory.CreateClient();
|
||||
using var msg = new FormUrlEncodedContent(new[]
|
||||
{
|
||||
new KeyValuePair<string, string>("user", _user), new KeyValuePair<string, string>("key", _key)
|
||||
});
|
||||
using var data = await http.PostAsync(_createEndpoint, msg);
|
||||
var str = await data.Content.ReadAsStringAsync();
|
||||
var obj = JsonConvert.DeserializeObject<CleverbotIoCreateResponse>(str);
|
||||
if (obj.Status != "success")
|
||||
throw new OperationCanceledException(obj.Status);
|
||||
|
||||
return obj.Nick;
|
||||
}
|
||||
|
||||
public async Task<string> Think(string input)
|
||||
{
|
||||
using var http = _httpFactory.CreateClient();
|
||||
using var msg = new FormUrlEncodedContent(new[]
|
||||
{
|
||||
new KeyValuePair<string, string>("user", _user), new KeyValuePair<string, string>("key", _key),
|
||||
new KeyValuePair<string, string>("nick", await _nick), new KeyValuePair<string, string>("text", input)
|
||||
});
|
||||
using var data = await http.PostAsync(_askEndpoint, msg);
|
||||
var str = await data.Content.ReadAsStringAsync();
|
||||
var obj = JsonConvert.DeserializeObject<CleverbotIoAskResponse>(str);
|
||||
if (obj.Status != "success")
|
||||
throw new OperationCanceledException(obj.Status);
|
||||
|
||||
return obj.Response;
|
||||
}
|
||||
}
|
@@ -212,7 +212,7 @@ public partial class Help : NadekoModule<HelpService>
|
||||
cmds = cmds.Where(x => succ.Contains(x)).ToList();
|
||||
}
|
||||
|
||||
var cmdsWithGroup = cmds.GroupBy(c => c.Module.Name.Replace("Commands", "", StringComparison.InvariantCulture))
|
||||
var cmdsWithGroup = cmds.GroupBy(c => c.Module.GetGroupName())
|
||||
.OrderBy(x => x.Key == x.First().Module.Name ? int.MaxValue : x.Count())
|
||||
.ToList();
|
||||
|
||||
@@ -294,7 +294,7 @@ public partial class Help : NadekoModule<HelpService>
|
||||
|
||||
if (fail.StartsWith(prefix))
|
||||
fail = fail.Substring(prefix.Length);
|
||||
|
||||
|
||||
var group = _cmds.Modules
|
||||
.SelectMany(x => x.Submodules)
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x.Group))
|
||||
@@ -393,11 +393,6 @@ public partial class Help : NadekoModule<HelpService>
|
||||
};
|
||||
|
||||
using var dlClient = new AmazonS3Client(accessKey, secretAcccessKey, config);
|
||||
using var oldVersionObject = await dlClient.GetObjectAsync(new()
|
||||
{
|
||||
BucketName = "nadeko-pictures",
|
||||
Key = "cmds/versions.json"
|
||||
});
|
||||
|
||||
using (var client = new AmazonS3Client(accessKey, secretAcccessKey, config))
|
||||
{
|
||||
@@ -407,14 +402,29 @@ public partial class Help : NadekoModule<HelpService>
|
||||
ContentType = "application/json",
|
||||
ContentBody = uploadData,
|
||||
// either use a path provided in the argument or the default one for public nadeko, other/cmds.json
|
||||
Key = $"cmds/{StatsService.BOT_VERSION}.json",
|
||||
Key = $"cmds/v4/{StatsService.BOT_VERSION}.json",
|
||||
CannedACL = S3CannedACL.PublicRead
|
||||
});
|
||||
}
|
||||
|
||||
await using var ms = new MemoryStream();
|
||||
await oldVersionObject.ResponseStream.CopyToAsync(ms);
|
||||
var versionListString = Encoding.UTF8.GetString(ms.ToArray());
|
||||
|
||||
var versionListString = "[]";
|
||||
try
|
||||
{
|
||||
using var oldVersionObject = await dlClient.GetObjectAsync(new()
|
||||
{
|
||||
BucketName = "nadeko-pictures",
|
||||
Key = "cmds/v4/versions.json"
|
||||
});
|
||||
|
||||
await using var ms = new MemoryStream();
|
||||
await oldVersionObject.ResponseStream.CopyToAsync(ms);
|
||||
versionListString = Encoding.UTF8.GetString(ms.ToArray());
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
Log.Information("No old version list found. Creating a new one.");
|
||||
}
|
||||
|
||||
var versionList = JsonSerializer.Deserialize<List<string>>(versionListString);
|
||||
if (versionList is not null && !versionList.Contains(StatsService.BOT_VERSION))
|
||||
@@ -435,7 +445,7 @@ public partial class Help : NadekoModule<HelpService>
|
||||
ContentType = "application/json",
|
||||
ContentBody = versionListString,
|
||||
// either use a path provided in the argument or the default one for public nadeko, other/cmds.json
|
||||
Key = "cmds/versions.json",
|
||||
Key = "cmds/v4/versions.json",
|
||||
CannedACL = S3CannedACL.PublicRead
|
||||
});
|
||||
}
|
||||
@@ -455,9 +465,71 @@ public partial class Help : NadekoModule<HelpService>
|
||||
[Cmd]
|
||||
public async partial Task Guide()
|
||||
=> await ConfirmLocalizedAsync(strs.guide("https://nadeko.bot/commands",
|
||||
"http://nadekobot.readthedocs.io/en/latest/"));
|
||||
"https://nadekobot.readthedocs.io/en/latest/"));
|
||||
|
||||
|
||||
private Task SelfhostAction(SocketMessageComponent smc)
|
||||
=> smc.RespondConfirmAsync(_eb,
|
||||
@"- In case you don't want or cannot Donate to NadekoBot project, but you
|
||||
- NadekoBot is a completely free and fully [open source](https://gitlab.com/kwoth/nadekobot) project which means you can run your own ""selfhosted"" instance on your computer or server for free.
|
||||
|
||||
*Keep in mind that running the bot on your computer means that the bot will be offline when you turn off your computer*
|
||||
|
||||
- You can find the selfhosting guides by using the `.guide` command and clicking on the second link that pops up.
|
||||
- If you decide to selfhost the bot, still consider [supporting the project](https://patreon.com/join/nadekobot) to keep the development going :)",
|
||||
true);
|
||||
|
||||
[Cmd]
|
||||
[OnlyPublicBot]
|
||||
public async partial Task Donate()
|
||||
=> await ReplyConfirmLocalizedAsync(strs.donate(PATREON_URL, PAYPAL_URL));
|
||||
{
|
||||
var selfhostInter = new DonateSelfhostingInteraction(_client, ctx.User.Id, SelfhostAction);
|
||||
|
||||
var eb = _eb.Create(ctx)
|
||||
.WithOkColor()
|
||||
.WithTitle("Thank you for considering to donate to the NadekoBot project!");
|
||||
|
||||
eb
|
||||
.WithDescription("NadekoBot relies on donations to keep the servers, services and APIs running.\n"
|
||||
+ "Donating will give you access to some exclusive features. You can read about them on the [patreon page](https://patreon.com/join/nadekobot)")
|
||||
.AddField("Donation Instructions",
|
||||
$@"
|
||||
🗒️ Before pledging it is recommended to open your DMs as Nadeko will send you a welcome message with instructions after you pledge has been processed and confirmed.
|
||||
|
||||
**Step 1:** ❤️ Pledge on Patreon ❤️
|
||||
|
||||
`1.` Go to <https://patreon.com/join/nadekobot> and choose a tier.
|
||||
`2.` Make sure your payment is processed and accepted.
|
||||
|
||||
**Step 2** 🤝 Connect your Discord account 🤝
|
||||
|
||||
`1.` Go to your profile settings on Patreon and connect your Discord account to it.
|
||||
*please make sure you're logged into the correct Discord account*
|
||||
|
||||
If you do not know how to do it, you may follow instructions in this link:
|
||||
<https://support.patreon.com/hc/en-us/articles/212052266-How-do-I-connect-Discord-to-Patreon-Patron->
|
||||
|
||||
**Step 3** ⏰ Wait a short while (usually 1-3 minutes) ⏰
|
||||
|
||||
Nadeko will DM you the welcome instructions, and you may start using the patron-only commands and features!
|
||||
🎉 **Enjoy!** 🎉
|
||||
")
|
||||
.AddField("Troubleshooting",
|
||||
@"
|
||||
*In case you didn't receive the rewards within 5 minutes:*
|
||||
`1.` Make sure your DMs are open to everyone. Maybe your pledge was processed successfully but the bot was unable to DM you. Use the `.patron` command to check your status.
|
||||
`2.` Make sure you've connected the CORRECT Discord account. Quite often users log in to different Discord accounts in their browser. You may also try disconnecting and reconnecting your account.
|
||||
`3.` Make sure your payment has been processed and not declined by Patreon.
|
||||
`4.` If any of the previous steps don't help, you can join the nadeko support server <https://discord.nadeko.bot> and ask for help in the #help channel");
|
||||
|
||||
try
|
||||
{
|
||||
await (await ctx.User.CreateDMChannelAsync()).EmbedAsync(eb, inter: selfhostInter.GetInteraction());
|
||||
_ = ctx.OkAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.cant_dm);
|
||||
}
|
||||
}
|
||||
}
|
@@ -131,6 +131,25 @@ public class HelpService : IExecNoCommand, INService
|
||||
|
||||
if (cmd.Preconditions.Any(x => x is OwnerOnlyAttribute))
|
||||
toReturn.Add("Bot Owner Only");
|
||||
|
||||
if(cmd.Preconditions.Any(x => x is NoPublicBotAttribute)
|
||||
|| cmd.Module
|
||||
.Preconditions
|
||||
.Any(x => x is NoPublicBotAttribute)
|
||||
|| cmd.Module.GetTopLevelModule()
|
||||
.Preconditions
|
||||
.Any(x => x is NoPublicBotAttribute))
|
||||
toReturn.Add("No Public Bot");
|
||||
|
||||
if (cmd.Preconditions
|
||||
.Any(x => x is OnlyPublicBotAttribute)
|
||||
|| cmd.Module
|
||||
.Preconditions
|
||||
.Any(x => x is OnlyPublicBotAttribute)
|
||||
|| cmd.Module.GetTopLevelModule()
|
||||
.Preconditions
|
||||
.Any(x => x is OnlyPublicBotAttribute))
|
||||
toReturn.Add("Only Public Bot");
|
||||
|
||||
var userPerm = (UserPermAttribute)cmd.Preconditions.FirstOrDefault(ca => ca is UserPermAttribute);
|
||||
|
||||
|
@@ -0,0 +1,12 @@
|
||||
namespace NadekoBot.Modules.Help;
|
||||
|
||||
public class DonateSelfhostingInteraction : NInteraction
|
||||
{
|
||||
protected override NadekoInteractionData Data
|
||||
=> new NadekoInteractionData(new Emoji("🖥️"), "donate:selfhosting", "Selfhosting");
|
||||
|
||||
public DonateSelfhostingInteraction(DiscordSocketClient client, ulong userId, Func<SocketMessageComponent, Task> action)
|
||||
: base(client, userId, action)
|
||||
{
|
||||
}
|
||||
}
|
@@ -0,0 +1,12 @@
|
||||
namespace NadekoBot.Modules.Help;
|
||||
|
||||
public class DonateTroubleshootInteraction : NInteraction
|
||||
{
|
||||
protected override NadekoInteractionData Data
|
||||
=> new NadekoInteractionData(new Emoji("❓"), "donate:troubleshoot", "Troubleshoot");
|
||||
|
||||
public DonateTroubleshootInteraction(DiscordSocketClient client, ulong userId, Func<SocketMessageComponent, Task> action)
|
||||
: base(client, userId, action)
|
||||
{
|
||||
}
|
||||
}
|
@@ -119,7 +119,7 @@ public sealed partial class YtLoader
|
||||
var responseSpan = response.AsSpan()[140_000..];
|
||||
var startIndex = responseSpan.IndexOf(_ytResultInitialData);
|
||||
if (startIndex == -1)
|
||||
return null; // todo future try selecting html
|
||||
return null; // FUTURE try selecting html
|
||||
startIndex += _ytResultInitialData.Length;
|
||||
|
||||
var endIndex =
|
||||
|
@@ -219,7 +219,7 @@ public sealed class MusicPlayer : IMusicPlayer
|
||||
errorCount = 0;
|
||||
}
|
||||
|
||||
// todo future windows multimedia api
|
||||
// FUTURE windows multimedia api
|
||||
|
||||
// wait for slightly less than the latency
|
||||
Thread.Sleep(delay);
|
||||
|
@@ -42,8 +42,6 @@ public class RadioResolver : IRadioResolver
|
||||
}
|
||||
|
||||
if (query.Contains(".pls"))
|
||||
//File1=http://armitunes.com:8000/
|
||||
//Regex.Match(query)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -59,11 +57,6 @@ public class RadioResolver : IRadioResolver
|
||||
}
|
||||
|
||||
if (query.Contains(".m3u"))
|
||||
/*
|
||||
# This is a comment
|
||||
C:\xxx4xx\xxxxxx3x\xx2xxxx\xx.mp3
|
||||
C:\xxx5xx\x6xxxxxx\x7xxxxx\xx.mp3
|
||||
*/
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -79,7 +72,6 @@ public class RadioResolver : IRadioResolver
|
||||
}
|
||||
|
||||
if (query.Contains(".asx"))
|
||||
//<ref href="http://armitunes.com:8000"/>
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -95,12 +87,6 @@ public class RadioResolver : IRadioResolver
|
||||
}
|
||||
|
||||
if (query.Contains(".xspf"))
|
||||
/*
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<playlist version="1" xmlns="http://xspf.org/ns/0/">
|
||||
<trackList>
|
||||
<track><location>file:///mp3s/song_1.mp3</location></track>
|
||||
*/
|
||||
{
|
||||
try
|
||||
{
|
||||
|
@@ -0,0 +1,10 @@
|
||||
#nullable disable
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace NadekoBot.Modules.Permissions;
|
||||
|
||||
[StructLayout(LayoutKind.Sequential, Size = 1)]
|
||||
public readonly struct CleverBotResponseStr
|
||||
{
|
||||
public const string CLEVERBOT_RESPONSE = "cleverbot:response";
|
||||
}
|
@@ -0,0 +1,15 @@
|
||||
#nullable disable
|
||||
using NadekoBot.Common.TypeReaders;
|
||||
using static NadekoBot.Common.TypeReaders.TypeReaderResult;
|
||||
|
||||
namespace NadekoBot.Modules.Permissions;
|
||||
|
||||
public class CleverbotResponseCmdCdTypeReader : NadekoTypeReader<CleverBotResponseStr>
|
||||
{
|
||||
public override ValueTask<TypeReaderResult<CleverBotResponseStr>> ReadAsync(
|
||||
ICommandContext ctx,
|
||||
string input)
|
||||
=> input.ToLowerInvariant() == CleverBotResponseStr.CLEVERBOT_RESPONSE
|
||||
? new(FromSuccess(new CleverBotResponseStr()))
|
||||
: new(FromError<CleverBotResponseStr>(CommandError.ParseFailed, "Not a valid cleverbot"));
|
||||
}
|
@@ -27,9 +27,7 @@ public partial class Permissions
|
||||
_db = db;
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
public async partial Task CmdCooldown(CommandOrCrInfo command, int secs)
|
||||
private async Task CmdCooldownInternal(string cmdName, int secs)
|
||||
{
|
||||
var channel = (ITextChannel)ctx.Channel;
|
||||
if (secs is < 0 or > 3600)
|
||||
@@ -38,7 +36,7 @@ public partial class Permissions
|
||||
return;
|
||||
}
|
||||
|
||||
var name = command.Name.ToLowerInvariant();
|
||||
var name = cmdName.ToLowerInvariant();
|
||||
await using (var uow = _db.GetDbContext())
|
||||
{
|
||||
var config = uow.GuildConfigsForId(channel.Guild.Id, set => set.Include(gc => gc.CommandCooldowns));
|
||||
@@ -71,6 +69,18 @@ public partial class Permissions
|
||||
else
|
||||
await ReplyConfirmLocalizedAsync(strs.cmdcd_add(Format.Bold(name), Format.Bold(secs.ToString())));
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[Priority(0)]
|
||||
public partial Task CmdCooldown(CleverBotResponseStr command, int secs)
|
||||
=> CmdCooldownInternal(CleverBotResponseStr.CLEVERBOT_RESPONSE, secs);
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[Priority(1)]
|
||||
public partial Task CmdCooldown(CommandOrCrInfo command, int secs)
|
||||
=> CmdCooldownInternal(command.Name, secs);
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
|
@@ -1,6 +1,8 @@
|
||||
#nullable disable
|
||||
using CodeHollow.FeedReader;
|
||||
using CodeHollow.FeedReader.Feeds;
|
||||
using LinqToDB;
|
||||
using LinqToDB.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NadekoBot.Db;
|
||||
using NadekoBot.Services.Database.Models;
|
||||
@@ -10,11 +12,12 @@ namespace NadekoBot.Modules.Searches.Services;
|
||||
public class FeedsService : INService
|
||||
{
|
||||
private readonly DbService _db;
|
||||
private readonly ConcurrentDictionary<string, HashSet<FeedSub>> _subs;
|
||||
private readonly ConcurrentDictionary<string, List<FeedSub>> _subs;
|
||||
private readonly DiscordSocketClient _client;
|
||||
private readonly IEmbedBuilderService _eb;
|
||||
|
||||
private readonly ConcurrentDictionary<string, DateTime> _lastPosts = new();
|
||||
private readonly Dictionary<string, uint> _errorCounters = new();
|
||||
|
||||
public FeedsService(
|
||||
Bot bot,
|
||||
@@ -33,7 +36,7 @@ public class FeedsService : INService
|
||||
.ToList()
|
||||
.SelectMany(x => x.FeedSubs)
|
||||
.GroupBy(x => x.Url.ToLower())
|
||||
.ToDictionary(x => x.Key, x => x.ToHashSet())
|
||||
.ToDictionary(x => x.Key, x => x.ToList())
|
||||
.ToConcurrent();
|
||||
}
|
||||
|
||||
@@ -43,6 +46,35 @@ public class FeedsService : INService
|
||||
_ = Task.Run(TrackFeeds);
|
||||
}
|
||||
|
||||
private void ClearErrors(string url)
|
||||
=> _errorCounters.Remove(url);
|
||||
|
||||
private async Task AddError(string url, List<int> ids)
|
||||
{
|
||||
try
|
||||
{
|
||||
var newValue = _errorCounters[url] = _errorCounters.GetValueOrDefault(url) + 1;
|
||||
|
||||
if (newValue >= 100)
|
||||
{
|
||||
// remove from db
|
||||
await using var ctx = _db.GetDbContext();
|
||||
await ctx.GetTable<FeedSub>()
|
||||
.DeleteAsync(x => ids.Contains(x.Id));
|
||||
|
||||
// remove from the local cache
|
||||
_subs.TryRemove(url, out _);
|
||||
|
||||
// reset the error counter
|
||||
ClearErrors(url);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Error adding rss errors...");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<EmbedBuilder> TrackFeeds()
|
||||
{
|
||||
while (true)
|
||||
@@ -134,13 +166,17 @@ public class FeedsService : INService
|
||||
embed.WithDescription(desc.TrimTo(2048));
|
||||
|
||||
//send the created embed to all subscribed channels
|
||||
var feedSendTasks = kvp.Value.Where(x => x.GuildConfig is not null)
|
||||
var feedSendTasks = kvp.Value
|
||||
.Where(x => x.GuildConfig is not null)
|
||||
.Select(x => _client.GetGuild(x.GuildConfig.GuildId)
|
||||
?.GetTextChannel(x.ChannelId))
|
||||
.Where(x => x is not null)
|
||||
.Select(x => x.EmbedAsync(embed));
|
||||
|
||||
allSendTasks.Add(feedSendTasks.WhenAll());
|
||||
|
||||
// as data retrieval was sucessful, reset error counter
|
||||
ClearErrors(rssUrl);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -149,6 +185,8 @@ public class FeedsService : INService
|
||||
+ "\n {Message}",
|
||||
rssUrl,
|
||||
$"[{ex.GetType().Name}]: {ex.Message}");
|
||||
|
||||
await AddError(rssUrl, kvp.Value.Select(x => x.Id).ToList());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,7 +226,7 @@ public class FeedsService : INService
|
||||
foreach (var feed in gc.FeedSubs)
|
||||
{
|
||||
_subs.AddOrUpdate(feed.Url.ToLower(),
|
||||
new HashSet<FeedSub>
|
||||
new List<FeedSub>
|
||||
{
|
||||
feed
|
||||
},
|
||||
@@ -216,7 +254,7 @@ public class FeedsService : INService
|
||||
return false;
|
||||
var toRemove = items[index];
|
||||
_subs.AddOrUpdate(toRemove.Url.ToLower(),
|
||||
new HashSet<FeedSub>(),
|
||||
new List<FeedSub>(),
|
||||
(_, old) =>
|
||||
{
|
||||
old.Remove(toRemove);
|
||||
|
@@ -0,0 +1,60 @@
|
||||
using NadekoBot.Modules.Searches.Youtube;
|
||||
|
||||
namespace NadekoBot.Modules.Searches;
|
||||
|
||||
public sealed class DefaultSearchServiceFactory : ISearchServiceFactory, INService
|
||||
{
|
||||
private readonly SearchesConfigService _scs;
|
||||
private readonly SearxSearchService _sss;
|
||||
private readonly GoogleSearchService _gss;
|
||||
|
||||
private readonly YtdlpYoutubeSearchService _ytdlp;
|
||||
private readonly YtdlYoutubeSearchService _ytdl;
|
||||
private readonly YoutubeDataApiSearchService _ytdata;
|
||||
private readonly InvidiousYtSearchService _iYtSs;
|
||||
|
||||
public DefaultSearchServiceFactory(
|
||||
SearchesConfigService scs,
|
||||
GoogleSearchService gss,
|
||||
SearxSearchService sss,
|
||||
YtdlpYoutubeSearchService ytdlp,
|
||||
YtdlYoutubeSearchService ytdl,
|
||||
YoutubeDataApiSearchService ytdata,
|
||||
InvidiousYtSearchService iYtSs)
|
||||
{
|
||||
_scs = scs;
|
||||
_sss = sss;
|
||||
_gss = gss;
|
||||
_iYtSs = iYtSs;
|
||||
|
||||
_ytdlp = ytdlp;
|
||||
_ytdl = ytdl;
|
||||
_ytdata = ytdata;
|
||||
}
|
||||
|
||||
public ISearchService GetSearchService(string? hint = null)
|
||||
=> _scs.Data.WebSearchEngine switch
|
||||
{
|
||||
WebSearchEngine.Google => _gss,
|
||||
WebSearchEngine.Searx => _sss,
|
||||
_ => _gss
|
||||
};
|
||||
|
||||
public ISearchService GetImageSearchService(string? hint = null)
|
||||
=> _scs.Data.ImgSearchEngine switch
|
||||
{
|
||||
ImgSearchEngine.Google => _gss,
|
||||
ImgSearchEngine.Searx => _sss,
|
||||
_ => _gss
|
||||
};
|
||||
|
||||
public IYoutubeSearchService GetYoutubeSearchService(string? hint = null)
|
||||
=> _scs.Data.YtProvider switch
|
||||
{
|
||||
YoutubeSearcher.YtDataApiv3 => _ytdata,
|
||||
YoutubeSearcher.Ytdlp => _ytdlp,
|
||||
YoutubeSearcher.Ytdl => _ytdl,
|
||||
YoutubeSearcher.Invidious => _iYtSs,
|
||||
_ => _ytdl
|
||||
};
|
||||
}
|
@@ -0,0 +1,65 @@
|
||||
// using AngleSharp.Html.Dom;
|
||||
// using MorseCode.ITask;
|
||||
// using NadekoBot.Modules.Searches.Common;
|
||||
// using System.Net;
|
||||
//
|
||||
// namespace NadekoBot.Modules.Searches.DuckDuckGo;
|
||||
//
|
||||
// public sealed class DuckDuckGoSeachService : SearchServiceBase
|
||||
// {
|
||||
// private static readonly HtmlParser _googleParser = new(new()
|
||||
// {
|
||||
// IsScripting = false,
|
||||
// IsEmbedded = false,
|
||||
// IsSupportingProcessingInstructions = false,
|
||||
// IsKeepingSourceReferences = false,
|
||||
// IsNotSupportingFrames = true
|
||||
// });
|
||||
//
|
||||
// public override async ITask<SearchResultData> SearchAsync(string query)
|
||||
// {
|
||||
// query = WebUtility.UrlEncode(query)?.Replace(' ', '+');
|
||||
//
|
||||
// var fullQueryLink = "https://html.duckduckgo.com/html";
|
||||
//
|
||||
// using var http = _httpFactory.CreateClient();
|
||||
// http.DefaultRequestHeaders.Clear();
|
||||
// http.DefaultRequestHeaders.Add("User-Agent",
|
||||
// "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.77 Safari/537.36");
|
||||
//
|
||||
// using var formData = new MultipartFormDataContent();
|
||||
// formData.Add(new StringContent(query), "q");
|
||||
// using var response = await http.PostAsync(fullQueryLink, formData);
|
||||
// var content = await response.Content.ReadAsStringAsync();
|
||||
//
|
||||
// using var document = await _googleParser.ParseDocumentAsync(content);
|
||||
// var searchResults = document.QuerySelector(".results");
|
||||
// var elems = searchResults.QuerySelectorAll(".result");
|
||||
//
|
||||
// if (!elems.Any())
|
||||
// return default;
|
||||
//
|
||||
// var results = elems.Select(elem =>
|
||||
// {
|
||||
// if (elem.QuerySelector(".result__a") is not IHtmlAnchorElement anchor)
|
||||
// return null;
|
||||
//
|
||||
// var href = anchor.Href;
|
||||
// var name = anchor.TextContent;
|
||||
//
|
||||
// if (string.IsNullOrWhiteSpace(href) || string.IsNullOrWhiteSpace(name))
|
||||
// return null;
|
||||
//
|
||||
// var txt = elem.QuerySelector(".result__snippet")?.TextContent;
|
||||
//
|
||||
// if (string.IsNullOrWhiteSpace(txt))
|
||||
// return null;
|
||||
//
|
||||
// return new GoogleSearchResult(name, href, txt);
|
||||
// })
|
||||
// .Where(x => x is not null)
|
||||
// .ToList();
|
||||
//
|
||||
// return new(results.AsReadOnly(), fullQueryLink, "0");
|
||||
// }
|
||||
// }
|
@@ -0,0 +1,22 @@
|
||||
using NadekoBot.Modules.Searches;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace NadekoBot.Services;
|
||||
|
||||
public sealed class GoogleCustomSearchResult : ISearchResult
|
||||
{
|
||||
ISearchResultInformation ISearchResult.Info
|
||||
=> Info;
|
||||
|
||||
public string? Answer
|
||||
=> null;
|
||||
|
||||
IReadOnlyCollection<ISearchResultEntry> ISearchResult.Entries
|
||||
=> Entries ?? Array.Empty<OfficialGoogleSearchResultEntry>();
|
||||
|
||||
[JsonPropertyName("searchInformation")]
|
||||
public GoogleSearchResultInformation Info { get; init; } = null!;
|
||||
|
||||
[JsonPropertyName("items")]
|
||||
public IReadOnlyCollection<OfficialGoogleSearchResultEntry>? Entries { get; init; }
|
||||
}
|
@@ -0,0 +1,12 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace NadekoBot.Services;
|
||||
|
||||
public sealed class GoogleImageData
|
||||
{
|
||||
[JsonPropertyName("contextLink")]
|
||||
public string ContextLink { get; init; } = null!;
|
||||
|
||||
[JsonPropertyName("thumbnailLink")]
|
||||
public string ThumbnailLink { get; init; } = null!;
|
||||
}
|
@@ -0,0 +1,19 @@
|
||||
using NadekoBot.Modules.Searches;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace NadekoBot.Services;
|
||||
|
||||
public sealed class GoogleImageResult : IImageSearchResult
|
||||
{
|
||||
ISearchResultInformation IImageSearchResult.Info
|
||||
=> Info;
|
||||
|
||||
IReadOnlyCollection<IImageSearchResultEntry> IImageSearchResult.Entries
|
||||
=> Entries ?? Array.Empty<GoogleImageResultEntry>();
|
||||
|
||||
[JsonPropertyName("searchInformation")]
|
||||
public GoogleSearchResultInformation Info { get; init; } = null!;
|
||||
|
||||
[JsonPropertyName("items")]
|
||||
public IReadOnlyCollection<GoogleImageResultEntry>? Entries { get; init; }
|
||||
}
|
@@ -0,0 +1,13 @@
|
||||
using NadekoBot.Modules.Searches;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace NadekoBot.Services;
|
||||
|
||||
public sealed class GoogleImageResultEntry : IImageSearchResultEntry
|
||||
{
|
||||
[JsonPropertyName("link")]
|
||||
public string Link { get; init; } = null!;
|
||||
|
||||
[JsonPropertyName("image")]
|
||||
public GoogleImageData Image { get; init; } = null!;
|
||||
}
|
@@ -0,0 +1,13 @@
|
||||
using NadekoBot.Modules.Searches;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace NadekoBot.Services;
|
||||
|
||||
public sealed class GoogleSearchResultInformation : ISearchResultInformation
|
||||
{
|
||||
[JsonPropertyName("formattedTotalResults")]
|
||||
public string TotalResults { get; init; } = null!;
|
||||
|
||||
[JsonPropertyName("formattedSearchTime")]
|
||||
public string SearchTime { get; init; } = null!;
|
||||
}
|
@@ -0,0 +1,66 @@
|
||||
using MorseCode.ITask;
|
||||
|
||||
namespace NadekoBot.Modules.Searches;
|
||||
|
||||
public sealed class GoogleSearchService : SearchServiceBase, INService
|
||||
{
|
||||
private readonly IBotCredsProvider _creds;
|
||||
private readonly IHttpClientFactory _httpFactory;
|
||||
|
||||
public GoogleSearchService(IBotCredsProvider creds, IHttpClientFactory httpFactory)
|
||||
{
|
||||
_creds = creds;
|
||||
_httpFactory = httpFactory;
|
||||
}
|
||||
|
||||
public override async ITask<GoogleImageResult?> SearchImagesAsync(string query)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
|
||||
var creds = _creds.GetCreds();
|
||||
var key = creds.Google.ImageSearchId;
|
||||
var cx = string.IsNullOrWhiteSpace(key)
|
||||
? "c3f56de3be2034c07"
|
||||
: key;
|
||||
|
||||
using var http = _httpFactory.CreateClient("google:search");
|
||||
http.DefaultRequestHeaders.Add("Accept-Encoding", "gzip");
|
||||
await using var stream = await http.GetStreamAsync(
|
||||
$"https://customsearch.googleapis.com/customsearch/v1"
|
||||
+ $"?cx={cx}"
|
||||
+ $"&q={Uri.EscapeDataString(query)}"
|
||||
+ $"&fields=items(image(contextLink%2CthumbnailLink)%2Clink)%2CsearchInformation"
|
||||
+ $"&key={creds.GoogleApiKey}"
|
||||
+ $"&searchType=image"
|
||||
+ $"&safe=active");
|
||||
|
||||
var result = await System.Text.Json.JsonSerializer.DeserializeAsync<GoogleImageResult>(stream);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public override async ITask<GoogleCustomSearchResult?> SearchAsync(string query)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
|
||||
var creds = _creds.GetCreds();
|
||||
var key = creds.Google.SearchId;
|
||||
var cx = string.IsNullOrWhiteSpace(key)
|
||||
? "c7f1dac95987d4571"
|
||||
: key;
|
||||
|
||||
using var http = _httpFactory.CreateClient("google:search");
|
||||
http.DefaultRequestHeaders.Add("Accept-Encoding", "gzip");
|
||||
await using var stream = await http.GetStreamAsync(
|
||||
$"https://customsearch.googleapis.com/customsearch/v1"
|
||||
+ $"?cx={cx}"
|
||||
+ $"&q={Uri.EscapeDataString(query)}"
|
||||
+ $"&fields=items(title%2Clink%2CdisplayLink%2Csnippet)%2CsearchInformation"
|
||||
+ $"&key={creds.GoogleApiKey}"
|
||||
+ $"&safe=active");
|
||||
|
||||
var result = await System.Text.Json.JsonSerializer.DeserializeAsync<GoogleCustomSearchResult>(stream);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
@@ -0,0 +1,19 @@
|
||||
using NadekoBot.Modules.Searches;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace NadekoBot.Services;
|
||||
|
||||
public sealed class OfficialGoogleSearchResultEntry : ISearchResultEntry
|
||||
{
|
||||
[JsonPropertyName("title")]
|
||||
public string Title { get; init; } = null!;
|
||||
|
||||
[JsonPropertyName("link")]
|
||||
public string Url { get; init; } = null!;
|
||||
|
||||
[JsonPropertyName("displayLink")]
|
||||
public string DisplayUrl { get; init; } = null!;
|
||||
|
||||
[JsonPropertyName("snippet")]
|
||||
public string Description { get; init; } = null!;
|
||||
}
|
@@ -0,0 +1,62 @@
|
||||
// using AngleSharp.Html.Dom;
|
||||
// using MorseCode.ITask;
|
||||
// using NadekoBot.Modules.Searches.Common;
|
||||
//
|
||||
// namespace NadekoBot.Modules.Searches.GoogleScrape;
|
||||
//
|
||||
// public sealed class GoogleScrapeService : SearchServiceBase
|
||||
// {
|
||||
// public override async ITask<GoogleSearchResultData> SearchAsync(string query)
|
||||
// {
|
||||
// ArgumentNullException.ThrowIfNull(query);
|
||||
//
|
||||
// query = Uri.EscapeDataString(query)?.Replace(' ', '+');
|
||||
//
|
||||
// var fullQueryLink = $"https://www.google.ca/search?q={query}&safe=on&lr=lang_eng&hl=en&ie=utf-8&oe=utf-8";
|
||||
//
|
||||
// using var msg = new HttpRequestMessage(HttpMethod.Get, fullQueryLink);
|
||||
// msg.Headers.Add("User-Agent",
|
||||
// "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.77 Safari/537.36");
|
||||
// msg.Headers.Add("Cookie", "CONSENT=YES+shp.gws-20210601-0-RC2.en+FX+423;");
|
||||
//
|
||||
// using var http = _httpFactory.CreateClient();
|
||||
// http.DefaultRequestHeaders.Clear();
|
||||
//
|
||||
// using var response = await http.SendAsync(msg);
|
||||
// await using var content = await response.Content.ReadAsStreamAsync();
|
||||
//
|
||||
// using var document = await _googleParser.ParseDocumentAsync(content);
|
||||
// var elems = document.QuerySelectorAll("div.g > div > div");
|
||||
//
|
||||
// var resultsElem = document.QuerySelectorAll("#resultStats").FirstOrDefault();
|
||||
// var totalResults = resultsElem?.TextContent;
|
||||
// //var time = resultsElem.Children.FirstOrDefault()?.TextContent
|
||||
// //^ this doesn't work for some reason, <nobr> is completely missing in parsed collection
|
||||
// if (!elems.Any())
|
||||
// return default;
|
||||
//
|
||||
// var results = elems.Select(elem =>
|
||||
// {
|
||||
// var children = elem.Children.ToList();
|
||||
// if (children.Count < 2)
|
||||
// return null;
|
||||
//
|
||||
// var href = (children[0].QuerySelector("a") as IHtmlAnchorElement)?.Href;
|
||||
// var name = children[0].QuerySelector("h3")?.TextContent;
|
||||
//
|
||||
// if (href is null || name is null)
|
||||
// return null;
|
||||
//
|
||||
// var txt = children[1].TextContent;
|
||||
//
|
||||
// if (string.IsNullOrWhiteSpace(txt))
|
||||
// return null;
|
||||
//
|
||||
// return new GoogleSearchResult(name, href, txt);
|
||||
// })
|
||||
// .Where(x => x is not null)
|
||||
// .ToList();
|
||||
//
|
||||
// return new(results.AsReadOnly(), fullQueryLink, totalResults);
|
||||
// }
|
||||
// }
|
13
src/NadekoBot/Modules/Searches/Search/IImageSearchResult.cs
Normal file
13
src/NadekoBot/Modules/Searches/Search/IImageSearchResult.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace NadekoBot.Modules.Searches;
|
||||
|
||||
public interface IImageSearchResult
|
||||
{
|
||||
ISearchResultInformation Info { get; }
|
||||
|
||||
IReadOnlyCollection<IImageSearchResultEntry> Entries { get; }
|
||||
}
|
||||
|
||||
public interface IImageSearchResultEntry
|
||||
{
|
||||
string Link { get; }
|
||||
}
|
8
src/NadekoBot/Modules/Searches/Search/ISearchResult.cs
Normal file
8
src/NadekoBot/Modules/Searches/Search/ISearchResult.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace NadekoBot.Modules.Searches;
|
||||
|
||||
public interface ISearchResult
|
||||
{
|
||||
string? Answer { get; }
|
||||
IReadOnlyCollection<ISearchResultEntry> Entries { get; }
|
||||
ISearchResultInformation Info { get; }
|
||||
}
|
@@ -0,0 +1,9 @@
|
||||
namespace NadekoBot.Modules.Searches;
|
||||
|
||||
public interface ISearchResultEntry
|
||||
{
|
||||
string Title { get; }
|
||||
string Url { get; }
|
||||
string DisplayUrl { get; }
|
||||
string? Description { get; }
|
||||
}
|
@@ -0,0 +1,7 @@
|
||||
namespace NadekoBot.Modules.Searches;
|
||||
|
||||
public interface ISearchResultInformation
|
||||
{
|
||||
string TotalResults { get; }
|
||||
string SearchTime { get; }
|
||||
}
|
9
src/NadekoBot/Modules/Searches/Search/ISearchService.cs
Normal file
9
src/NadekoBot/Modules/Searches/Search/ISearchService.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
using MorseCode.ITask;
|
||||
|
||||
namespace NadekoBot.Modules.Searches;
|
||||
|
||||
public interface ISearchService
|
||||
{
|
||||
ITask<ISearchResult?> SearchAsync(string query);
|
||||
ITask<IImageSearchResult?> SearchImagesAsync(string query);
|
||||
}
|
@@ -0,0 +1,10 @@
|
||||
using NadekoBot.Modules.Searches.Youtube;
|
||||
|
||||
namespace NadekoBot.Modules.Searches;
|
||||
|
||||
public interface ISearchServiceFactory
|
||||
{
|
||||
public ISearchService GetSearchService(string? hint = null);
|
||||
public ISearchService GetImageSearchService(string? hint = null);
|
||||
public IYoutubeSearchService GetYoutubeSearchService(string? hint = null);
|
||||
}
|
206
src/NadekoBot/Modules/Searches/Search/SearchCommands.cs
Normal file
206
src/NadekoBot/Modules/Searches/Search/SearchCommands.cs
Normal file
@@ -0,0 +1,206 @@
|
||||
using NadekoBot.Modules.Searches.Youtube;
|
||||
using StackExchange.Redis;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace NadekoBot.Modules.Searches;
|
||||
|
||||
public partial class Searches
|
||||
{
|
||||
public partial class SearchCommands : NadekoModule
|
||||
{
|
||||
private readonly ISearchServiceFactory _searchFactory;
|
||||
private readonly ConnectionMultiplexer _redis;
|
||||
|
||||
public SearchCommands(
|
||||
ISearchServiceFactory searchFactory,
|
||||
ConnectionMultiplexer redis)
|
||||
{
|
||||
_searchFactory = searchFactory;
|
||||
_redis = redis;
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
public async partial Task Google([Leftover] string? query = null)
|
||||
{
|
||||
query = query?.Trim();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
await ErrorLocalizedAsync(strs.specify_search_params);
|
||||
return;
|
||||
}
|
||||
|
||||
_ = ctx.Channel.TriggerTypingAsync();
|
||||
|
||||
var search = _searchFactory.GetSearchService();
|
||||
var data = await search.SearchAsync(query);
|
||||
|
||||
if (data is null or { Entries: null or { Count: 0 } })
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.no_results);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3 with an answer
|
||||
// 4 without an answer
|
||||
// 5 is ideal but it lookes horrible on mobile
|
||||
|
||||
var takeCount = string.IsNullOrWhiteSpace(data.Answer)
|
||||
? 4
|
||||
: 3;
|
||||
|
||||
var descStr = data.Entries
|
||||
.Take(takeCount)
|
||||
.Select(static res => $@"**[{Format.Sanitize(res.Title)}]({res.Url})**
|
||||
*{Format.EscapeUrl(res.DisplayUrl)}*
|
||||
{Format.Sanitize(res.Description ?? "-")}")
|
||||
.Join("\n\n");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(data.Answer))
|
||||
descStr = Format.Code(data.Answer) + "\n\n" + descStr;
|
||||
|
||||
descStr = descStr.TrimTo(4096);
|
||||
|
||||
var embed = _eb.Create()
|
||||
.WithOkColor()
|
||||
.WithAuthor(ctx.User)
|
||||
.WithTitle(query.TrimTo(64)!)
|
||||
.WithDescription(descStr)
|
||||
.WithFooter(
|
||||
GetText(strs.results_in(data.Info.TotalResults, data.Info.SearchTime)),
|
||||
"https://i.imgur.com/G46fm8J.png");
|
||||
|
||||
await ctx.Channel.EmbedAsync(embed);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
public async partial Task Image([Leftover] string? query = null)
|
||||
{
|
||||
query = query?.Trim();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
await ErrorLocalizedAsync(strs.specify_search_params);
|
||||
return;
|
||||
}
|
||||
|
||||
_ = ctx.Channel.TriggerTypingAsync();
|
||||
|
||||
var search = _searchFactory.GetImageSearchService();
|
||||
var data = await search.SearchImagesAsync(query);
|
||||
|
||||
if (data is null or { Entries: null or { Count: 0 } })
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.no_search_results);
|
||||
return;
|
||||
}
|
||||
|
||||
var embeds = new List<IEmbedBuilder>(4);
|
||||
|
||||
|
||||
IEmbedBuilder CreateEmbed(IImageSearchResultEntry entry)
|
||||
{
|
||||
return _eb.Create(ctx)
|
||||
.WithOkColor()
|
||||
.WithAuthor(ctx.User)
|
||||
.WithTitle(query)
|
||||
.WithUrl("https://google.com")
|
||||
.WithImageUrl(entry.Link);
|
||||
}
|
||||
|
||||
embeds.Add(CreateEmbed(data.Entries.First())
|
||||
.WithFooter(
|
||||
GetText(strs.results_in(data.Info.TotalResults, data.Info.SearchTime)),
|
||||
"https://i.imgur.com/G46fm8J.png"));
|
||||
|
||||
var random = data.Entries.Skip(1)
|
||||
.Shuffle()
|
||||
.Take(3)
|
||||
.ToArray();
|
||||
|
||||
foreach (var entry in random)
|
||||
{
|
||||
embeds.Add(CreateEmbed(entry));
|
||||
}
|
||||
|
||||
await ctx.Channel.EmbedAsync(null, embeds: embeds);
|
||||
}
|
||||
|
||||
private async Task AddYoutubeUrlToCacheAsync(string query, string url)
|
||||
{
|
||||
var db = _redis.GetDatabase();
|
||||
await db.StringSetAsync($"search:youtube:{query}", url, expiry: 1.Hours());
|
||||
}
|
||||
|
||||
private async Task<VideoInfo?> GetYoutubeUrlFromCacheAsync(string query)
|
||||
{
|
||||
var db = _redis.GetDatabase();
|
||||
var url = await db.StringGetAsync($"search:youtube:{query}");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(url))
|
||||
return null;
|
||||
|
||||
return new VideoInfo()
|
||||
{
|
||||
Url = url
|
||||
};
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
public async partial Task Youtube([Leftover] string? query = null)
|
||||
{
|
||||
query = query?.Trim();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
await ErrorLocalizedAsync(strs.specify_search_params);
|
||||
return;
|
||||
}
|
||||
|
||||
_ = ctx.Channel.TriggerTypingAsync();
|
||||
|
||||
var maybeResult = await GetYoutubeUrlFromCacheAsync(query)
|
||||
?? await _searchFactory.GetYoutubeSearchService().SearchAsync(query);
|
||||
if (maybeResult is not {} result || result is {Url: null})
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.no_results);
|
||||
return;
|
||||
}
|
||||
|
||||
await AddYoutubeUrlToCacheAsync(query, result.Url);
|
||||
await ctx.Channel.SendMessageAsync(result.Url);
|
||||
}
|
||||
|
||||
// [Cmd]
|
||||
// public async partial Task DuckDuckGo([Leftover] string query = null)
|
||||
// {
|
||||
// query = query?.Trim();
|
||||
// if (!await ValidateQuery(query))
|
||||
// return;
|
||||
//
|
||||
// _ = ctx.Channel.TriggerTypingAsync();
|
||||
//
|
||||
// var data = await _service.DuckDuckGoSearchAsync(query);
|
||||
// if (data is null)
|
||||
// {
|
||||
// await ReplyErrorLocalizedAsync(strs.no_results);
|
||||
// return;
|
||||
// }
|
||||
//
|
||||
// var desc = data.Results.Take(5)
|
||||
// .Select(res => $@"[**{res.Title}**]({res.Link})
|
||||
// {res.Text.TrimTo(380 - res.Title.Length - res.Link.Length)}");
|
||||
//
|
||||
// var descStr = string.Join("\n\n", desc);
|
||||
//
|
||||
// var embed = _eb.Create()
|
||||
// .WithAuthor(ctx.User.ToString(),
|
||||
// "https://upload.wikimedia.org/wikipedia/en/9/90/The_DuckDuckGo_Duck.png")
|
||||
// .WithDescription($"{GetText(strs.search_for)} **{query}**\n\n" + descStr)
|
||||
// .WithOkColor();
|
||||
//
|
||||
// await ctx.Channel.EmbedAsync(embed);
|
||||
// }
|
||||
}
|
||||
}
|
@@ -0,0 +1,9 @@
|
||||
using MorseCode.ITask;
|
||||
|
||||
namespace NadekoBot.Modules.Searches;
|
||||
|
||||
public abstract class SearchServiceBase : ISearchService
|
||||
{
|
||||
public abstract ITask<ISearchResult?> SearchAsync(string query);
|
||||
public abstract ITask<IImageSearchResult?> SearchImagesAsync(string query);
|
||||
}
|
@@ -0,0 +1,28 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace NadekoBot.Modules.Searches;
|
||||
|
||||
public sealed class SearxImageSearchResult : IImageSearchResult
|
||||
{
|
||||
public string SearchTime { get; set; } = null!;
|
||||
|
||||
public ISearchResultInformation Info
|
||||
=> new SearxSearchResultInformation()
|
||||
{
|
||||
SearchTime = SearchTime,
|
||||
TotalResults = NumberOfResults.ToString("N", CultureInfo.InvariantCulture)
|
||||
};
|
||||
|
||||
public IReadOnlyCollection<IImageSearchResultEntry> Entries
|
||||
=> Results;
|
||||
|
||||
[JsonPropertyName("results")]
|
||||
public List<SearxImageSearchResultEntry> Results { get; set; } = new List<SearxImageSearchResultEntry>();
|
||||
|
||||
[JsonPropertyName("query")]
|
||||
public string Query { get; set; } = null!;
|
||||
|
||||
[JsonPropertyName("number_of_results")]
|
||||
public double NumberOfResults { get; set; }
|
||||
}
|
@@ -0,0 +1,14 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace NadekoBot.Modules.Searches;
|
||||
|
||||
public sealed class SearxImageSearchResultEntry : IImageSearchResultEntry
|
||||
{
|
||||
public string Link
|
||||
=> ImageSource.StartsWith("//")
|
||||
? "https:" + ImageSource
|
||||
: ImageSource;
|
||||
|
||||
[JsonPropertyName("img_src")]
|
||||
public string ImageSource { get; set; } = string.Empty;
|
||||
}
|
30
src/NadekoBot/Modules/Searches/Search/Searx/SearxInfobox.cs
Normal file
30
src/NadekoBot/Modules/Searches/Search/Searx/SearxInfobox.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
// using System.Text.Json.Serialization;
|
||||
//
|
||||
// namespace NadekoBot.Modules.Searches;
|
||||
//
|
||||
// public sealed class SearxInfobox
|
||||
// {
|
||||
// [JsonPropertyName("infobox")]
|
||||
// public string Infobox { get; set; }
|
||||
//
|
||||
// [JsonPropertyName("id")]
|
||||
// public string Id { get; set; }
|
||||
//
|
||||
// [JsonPropertyName("content")]
|
||||
// public string Content { get; set; }
|
||||
//
|
||||
// [JsonPropertyName("img_src")]
|
||||
// public string ImgSrc { get; set; }
|
||||
//
|
||||
// [JsonPropertyName("urls")]
|
||||
// public List<SearxUrlData> Urls { get; } = new List<SearxUrlData>();
|
||||
//
|
||||
// [JsonPropertyName("engine")]
|
||||
// public string Engine { get; set; }
|
||||
//
|
||||
// [JsonPropertyName("engines")]
|
||||
// public List<string> Engines { get; } = new List<string>();
|
||||
//
|
||||
// [JsonPropertyName("attributes")]
|
||||
// public List<SearxSearchAttribute> Attributes { get; } = new List<SearxSearchAttribute>();
|
||||
// }
|
@@ -0,0 +1,15 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace NadekoBot.Modules.Searches;
|
||||
|
||||
public sealed class SearxSearchAttribute
|
||||
{
|
||||
[JsonPropertyName("label")]
|
||||
public string? Label { get; set; }
|
||||
|
||||
[JsonPropertyName("value")]
|
||||
public string? Value { get; set; }
|
||||
|
||||
[JsonPropertyName("entity")]
|
||||
public string? Entity { get; set; }
|
||||
}
|
@@ -0,0 +1,47 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace NadekoBot.Modules.Searches;
|
||||
|
||||
public sealed class SearxSearchResult : ISearchResult
|
||||
{
|
||||
[JsonPropertyName("query")]
|
||||
public string Query { get; set; } = null!;
|
||||
|
||||
[JsonPropertyName("number_of_results")]
|
||||
public double NumberOfResults { get; set; }
|
||||
|
||||
[JsonPropertyName("results")]
|
||||
public List<SearxSearchResultEntry> Results { get; set; } = new List<SearxSearchResultEntry>();
|
||||
|
||||
[JsonPropertyName("answers")]
|
||||
public List<string> Answers { get; set; } = new List<string>();
|
||||
//
|
||||
// [JsonPropertyName("corrections")]
|
||||
// public List<object> Corrections { get; } = new List<object>();
|
||||
|
||||
// [JsonPropertyName("infoboxes")]
|
||||
// public List<InfoboxModel> Infoboxes { get; } = new List<InfoboxModel>();
|
||||
//
|
||||
// [JsonPropertyName("suggestions")]
|
||||
// public List<string> Suggestions { get; } = new List<string>();
|
||||
|
||||
// [JsonPropertyName("unresponsive_engines")]
|
||||
// public List<object> UnresponsiveEngines { get; } = new List<object>();
|
||||
|
||||
|
||||
public string SearchTime { get; set; } = null!;
|
||||
|
||||
public IReadOnlyCollection<ISearchResultEntry> Entries
|
||||
=> Results;
|
||||
|
||||
public ISearchResultInformation Info
|
||||
=> new SearxSearchResultInformation()
|
||||
{
|
||||
SearchTime = SearchTime,
|
||||
TotalResults = NumberOfResults.ToString("N", CultureInfo.InvariantCulture)
|
||||
};
|
||||
|
||||
public string? Answer
|
||||
=> Answers.FirstOrDefault();
|
||||
}
|
@@ -0,0 +1,51 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace NadekoBot.Modules.Searches;
|
||||
|
||||
public sealed class SearxSearchResultEntry : ISearchResultEntry
|
||||
{
|
||||
public string DisplayUrl
|
||||
=> Url;
|
||||
|
||||
public string Description
|
||||
=> Content.TrimTo(768)!;
|
||||
|
||||
[JsonPropertyName("url")]
|
||||
public string Url { get; set; } = null!;
|
||||
|
||||
[JsonPropertyName("title")]
|
||||
public string Title { get; set; } = null!;
|
||||
|
||||
[JsonPropertyName("content")]
|
||||
public string? Content { get; set; }
|
||||
|
||||
// [JsonPropertyName("engine")]
|
||||
// public string Engine { get; set; }
|
||||
//
|
||||
// [JsonPropertyName("parsed_url")]
|
||||
// public List<string> ParsedUrl { get; } = new List<string>();
|
||||
//
|
||||
// [JsonPropertyName("template")]
|
||||
// public string Template { get; set; }
|
||||
//
|
||||
// [JsonPropertyName("engines")]
|
||||
// public List<string> Engines { get; } = new List<string>();
|
||||
//
|
||||
// [JsonPropertyName("positions")]
|
||||
// public List<int> Positions { get; } = new List<int>();
|
||||
//
|
||||
// [JsonPropertyName("score")]
|
||||
// public double Score { get; set; }
|
||||
//
|
||||
// [JsonPropertyName("category")]
|
||||
// public string Category { get; set; }
|
||||
//
|
||||
// [JsonPropertyName("pretty_url")]
|
||||
// public string PrettyUrl { get; set; }
|
||||
//
|
||||
// [JsonPropertyName("open_group")]
|
||||
// public bool OpenGroup { get; set; }
|
||||
//
|
||||
// [JsonPropertyName("close_group")]
|
||||
// public bool? CloseGroup { get; set; }
|
||||
}
|
@@ -0,0 +1,7 @@
|
||||
namespace NadekoBot.Modules.Searches;
|
||||
|
||||
public sealed class SearxSearchResultInformation : ISearchResultInformation
|
||||
{
|
||||
public string TotalResults { get; init; } = string.Empty;
|
||||
public string SearchTime { get; init; } = string.Empty;
|
||||
}
|
@@ -0,0 +1,76 @@
|
||||
using MorseCode.ITask;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace NadekoBot.Modules.Searches;
|
||||
|
||||
public sealed class SearxSearchService : SearchServiceBase, INService
|
||||
{
|
||||
private readonly IHttpClientFactory _http;
|
||||
private readonly SearchesConfigService _scs;
|
||||
|
||||
private static readonly Random _rng = new NadekoRandom();
|
||||
|
||||
public SearxSearchService(IHttpClientFactory http, SearchesConfigService scs)
|
||||
=> (_http, _scs) = (http, scs);
|
||||
|
||||
private string GetRandomInstance()
|
||||
{
|
||||
var instances = _scs.Data.SearxInstances;
|
||||
|
||||
if (instances is null or { Count: 0 })
|
||||
throw new InvalidOperationException("No searx instances specified in searches.yml");
|
||||
|
||||
return instances[_rng.Next(0, instances.Count)];
|
||||
}
|
||||
|
||||
public override async ITask<SearxSearchResult> SearchAsync(string query)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
|
||||
var instanceUrl = GetRandomInstance();
|
||||
|
||||
Log.Information("Using {Instance} instance for web search...", instanceUrl);
|
||||
var sw = Stopwatch.StartNew();
|
||||
using var http = _http.CreateClient();
|
||||
await using var res = await http.GetStreamAsync($"{instanceUrl}"
|
||||
+ $"?q={Uri.EscapeDataString(query)}"
|
||||
+ $"&format=json"
|
||||
+ $"&strict=2");
|
||||
|
||||
sw.Stop();
|
||||
var dat = await JsonSerializer.DeserializeAsync<SearxSearchResult>(res);
|
||||
|
||||
if (dat is null)
|
||||
return new SearxSearchResult();
|
||||
|
||||
dat.SearchTime = sw.Elapsed.TotalSeconds.ToString("N2", CultureInfo.InvariantCulture);
|
||||
return dat;
|
||||
}
|
||||
|
||||
public override async ITask<SearxImageSearchResult> SearchImagesAsync(string query)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
|
||||
var instanceUrl = GetRandomInstance();
|
||||
|
||||
Log.Information("Using {Instance} instance for img search...", instanceUrl);
|
||||
var sw = Stopwatch.StartNew();
|
||||
using var http = _http.CreateClient();
|
||||
await using var res = await http.GetStreamAsync($"{instanceUrl}"
|
||||
+ $"?q={Uri.EscapeDataString(query)}"
|
||||
+ $"&format=json"
|
||||
+ $"&category_images=on"
|
||||
+ $"&strict=2");
|
||||
|
||||
sw.Stop();
|
||||
var dat = await JsonSerializer.DeserializeAsync<SearxImageSearchResult>(res);
|
||||
|
||||
if (dat is null)
|
||||
return new SearxImageSearchResult();
|
||||
|
||||
dat.SearchTime = sw.Elapsed.TotalSeconds.ToString("N2", CultureInfo.InvariantCulture);
|
||||
return dat;
|
||||
}
|
||||
}
|
15
src/NadekoBot/Modules/Searches/Search/Searx/SearxUrlData.cs
Normal file
15
src/NadekoBot/Modules/Searches/Search/Searx/SearxUrlData.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace NadekoBot.Modules.Searches;
|
||||
|
||||
public sealed class SearxUrlData
|
||||
{
|
||||
[JsonPropertyName("title")]
|
||||
public string Title { get; set; } = null!;
|
||||
|
||||
[JsonPropertyName("url")]
|
||||
public string Url { get; set; } = null!;
|
||||
|
||||
[JsonPropertyName("official")]
|
||||
public bool? Official { get; set; }
|
||||
}
|
@@ -0,0 +1,6 @@
|
||||
namespace NadekoBot.Modules.Searches.Youtube;
|
||||
|
||||
public interface IYoutubeSearchService
|
||||
{
|
||||
Task<VideoInfo?> SearchAsync(string query);
|
||||
}
|
@@ -0,0 +1,9 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace NadekoBot.Modules.Searches;
|
||||
|
||||
public sealed class InvidiousSearchResponse
|
||||
{
|
||||
[JsonPropertyName("videoId")]
|
||||
public string VideoId { get; set; } = null!;
|
||||
}
|
@@ -0,0 +1,46 @@
|
||||
using NadekoBot.Modules.Searches.Youtube;
|
||||
using System.Net.Http.Json;
|
||||
|
||||
namespace NadekoBot.Modules.Searches;
|
||||
|
||||
public sealed class InvidiousYtSearchService : IYoutubeSearchService, INService
|
||||
{
|
||||
private readonly IHttpClientFactory _http;
|
||||
private readonly SearchesConfigService _scs;
|
||||
private readonly NadekoRandom _rng;
|
||||
|
||||
public InvidiousYtSearchService(
|
||||
IHttpClientFactory http,
|
||||
SearchesConfigService scs)
|
||||
{
|
||||
_http = http;
|
||||
_scs = scs;
|
||||
_rng = new();
|
||||
}
|
||||
|
||||
public async Task<VideoInfo?> SearchAsync(string query)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
|
||||
var instances = _scs.Data.InvidiousInstances;
|
||||
if (instances is null or { Count: 0 })
|
||||
{
|
||||
Log.Warning("Attempted to use Invidious as the .youtube provider but there are no 'invidiousInstances' "
|
||||
+ "specified in `data/searches.yml`");
|
||||
return null;
|
||||
}
|
||||
|
||||
var instance = instances[_rng.Next(0, instances.Count)];
|
||||
|
||||
using var http = _http.CreateClient();
|
||||
var res = await http.GetFromJsonAsync<List<InvidiousSearchResponse>>(
|
||||
$"{instance}/api/v1/search"
|
||||
+ $"?q={query}"
|
||||
+ $"&type=video");
|
||||
|
||||
if (res is null or {Count: 0})
|
||||
return null;
|
||||
|
||||
return new VideoInfo(res[0].VideoId);
|
||||
}
|
||||
}
|
@@ -0,0 +1,9 @@
|
||||
namespace NadekoBot.Modules.Searches.Youtube;
|
||||
|
||||
public readonly struct VideoInfo
|
||||
{
|
||||
public VideoInfo(string videoId)
|
||||
=> Url = $"https://youtube.com/watch?v={videoId}";
|
||||
|
||||
public string Url { get; init; }
|
||||
}
|
@@ -0,0 +1,26 @@
|
||||
namespace NadekoBot.Modules.Searches.Youtube;
|
||||
|
||||
public sealed class YoutubeDataApiSearchService : IYoutubeSearchService, INService
|
||||
{
|
||||
private readonly IGoogleApiService _gapi;
|
||||
|
||||
public YoutubeDataApiSearchService(IGoogleApiService gapi)
|
||||
{
|
||||
_gapi = gapi;
|
||||
}
|
||||
|
||||
public async Task<VideoInfo?> SearchAsync(string query)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
|
||||
var results = await _gapi.GetVideoLinksByKeywordAsync(query);
|
||||
var first = results.FirstOrDefault();
|
||||
if (first is null)
|
||||
return null;
|
||||
|
||||
return new()
|
||||
{
|
||||
Url = first
|
||||
};
|
||||
}
|
||||
}
|
@@ -0,0 +1,7 @@
|
||||
namespace NadekoBot.Modules.Searches.Youtube;
|
||||
|
||||
public sealed class YtdlYoutubeSearchService : YoutubedlxServiceBase, INService
|
||||
{
|
||||
public override async Task<VideoInfo?> SearchAsync(string query)
|
||||
=> await InternalGetInfoAsync(query, false);
|
||||
}
|
@@ -0,0 +1,7 @@
|
||||
namespace NadekoBot.Modules.Searches.Youtube;
|
||||
|
||||
public sealed class YtdlpYoutubeSearchService : YoutubedlxServiceBase, INService
|
||||
{
|
||||
public override async Task<VideoInfo?> SearchAsync(string query)
|
||||
=> await InternalGetInfoAsync(query, true);
|
||||
}
|
@@ -0,0 +1,34 @@
|
||||
namespace NadekoBot.Modules.Searches.Youtube;
|
||||
|
||||
public abstract class YoutubedlxServiceBase : IYoutubeSearchService
|
||||
{
|
||||
private YtdlOperation CreateYtdlOp(bool isYtDlp)
|
||||
=> new YtdlOperation("-4 "
|
||||
+ "--geo-bypass "
|
||||
+ "--encoding UTF8 "
|
||||
+ "--get-id "
|
||||
+ "--no-check-certificate "
|
||||
+ "--default-search "
|
||||
+ "\"ytsearch:\" -- \"{0}\"",
|
||||
isYtDlp: isYtDlp);
|
||||
|
||||
protected async Task<VideoInfo?> InternalGetInfoAsync(string query, bool isYtDlp)
|
||||
{
|
||||
var op = CreateYtdlOp(isYtDlp);
|
||||
var data = await op.GetDataAsync(query);
|
||||
var items = data?.Split('\n');
|
||||
if (items is null or { Length: 0 })
|
||||
return null;
|
||||
|
||||
var id = items.FirstOrDefault(x => x.Length is > 5 and < 15);
|
||||
if (id is null)
|
||||
return null;
|
||||
|
||||
return new VideoInfo()
|
||||
{
|
||||
Url = $"https://youtube.com/watch?v={id}"
|
||||
};
|
||||
}
|
||||
|
||||
public abstract Task<VideoInfo?> SearchAsync(string query);
|
||||
}
|
@@ -1,6 +1,4 @@
|
||||
#nullable disable
|
||||
using AngleSharp;
|
||||
using AngleSharp.Html.Dom;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using NadekoBot.Modules.Administration.Services;
|
||||
using NadekoBot.Modules.Searches.Common;
|
||||
@@ -11,9 +9,9 @@ using SixLabors.ImageSharp;
|
||||
using SixLabors.ImageSharp.Drawing.Processing;
|
||||
using SixLabors.ImageSharp.PixelFormats;
|
||||
using SixLabors.ImageSharp.Processing;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Net;
|
||||
using Color = SixLabors.ImageSharp.Color;
|
||||
using Configuration = AngleSharp.Configuration;
|
||||
|
||||
namespace NadekoBot.Modules.Searches;
|
||||
|
||||
@@ -92,7 +90,7 @@ public partial class Searches : NadekoModule<SearchesService>
|
||||
.AddField("🌇 " + Format.Bold(GetText(strs.sunset)), $"{sunset:HH:mm} {timezone}", true)
|
||||
.WithOkColor()
|
||||
.WithFooter("Powered by openweathermap.org",
|
||||
$"http://openweathermap.org/img/w/{data.Weather[0].Icon}.png");
|
||||
$"https://openweathermap.org/img/w/{data.Weather[0].Icon}.png");
|
||||
}
|
||||
|
||||
await ctx.Channel.EmbedAsync(embed);
|
||||
@@ -146,22 +144,6 @@ public partial class Searches : NadekoModule<SearchesService>
|
||||
await ctx.Channel.SendMessageAsync(embed: eb.Build());
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
public async partial Task Youtube([Leftover] string query = null)
|
||||
{
|
||||
if (!await ValidateQuery(query))
|
||||
return;
|
||||
|
||||
var result = (await _google.GetVideoLinksByKeywordAsync(query)).FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(result))
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.no_results);
|
||||
return;
|
||||
}
|
||||
|
||||
await ctx.Channel.SendMessageAsync(result);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
public async partial Task Movie([Leftover] string query = null)
|
||||
{
|
||||
@@ -180,7 +162,7 @@ public partial class Searches : NadekoModule<SearchesService>
|
||||
await ctx.Channel.EmbedAsync(_eb.Create()
|
||||
.WithOkColor()
|
||||
.WithTitle(movie.Title)
|
||||
.WithUrl($"http://www.imdb.com/title/{movie.ImdbId}/")
|
||||
.WithUrl($"https://www.imdb.com/title/{movie.ImdbId}/")
|
||||
.WithDescription(movie.Plot.TrimTo(1000))
|
||||
.AddField("Rating", movie.ImdbRating, true)
|
||||
.AddField("Genre", movie.Genre, true)
|
||||
@@ -210,67 +192,13 @@ public partial class Searches : NadekoModule<SearchesService>
|
||||
return ctx.Channel.EmbedAsync(_eb.Create().WithOkColor().WithImageUrl(url));
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
public async partial Task Image([Leftover] string query = null)
|
||||
{
|
||||
var oterms = query?.Trim();
|
||||
if (!await ValidateQuery(query))
|
||||
return;
|
||||
|
||||
query = WebUtility.UrlEncode(oterms)?.Replace(' ', '+');
|
||||
try
|
||||
{
|
||||
var res = await _google.GetImageAsync(oterms);
|
||||
var embed = _eb.Create()
|
||||
.WithOkColor()
|
||||
.WithAuthor(GetText(strs.image_search_for) + " " + oterms.TrimTo(50),
|
||||
"http://i.imgur.com/G46fm8J.png",
|
||||
$"https://www.google.rs/search?q={query}&source=lnms&tbm=isch")
|
||||
.WithDescription(res.Link)
|
||||
.WithImageUrl(res.Link)
|
||||
.WithTitle(ctx.User.ToString());
|
||||
await ctx.Channel.EmbedAsync(embed);
|
||||
}
|
||||
catch
|
||||
{
|
||||
Log.Warning("Falling back to Imgur");
|
||||
|
||||
var fullQueryLink = $"http://imgur.com/search?q={query}";
|
||||
var config = Configuration.Default.WithDefaultLoader();
|
||||
using var document = await BrowsingContext.New(config).OpenAsync(fullQueryLink);
|
||||
var elems = document.QuerySelectorAll("a.image-list-link").ToList();
|
||||
|
||||
if (!elems.Any())
|
||||
return;
|
||||
|
||||
var img =
|
||||
elems.ElementAtOrDefault(new NadekoRandom().Next(0, elems.Count))?.Children?.FirstOrDefault() as
|
||||
IHtmlImageElement;
|
||||
|
||||
if (img?.Source is null)
|
||||
return;
|
||||
|
||||
var source = img.Source.Replace("b.", ".", StringComparison.InvariantCulture);
|
||||
|
||||
var embed = _eb.Create()
|
||||
.WithOkColor()
|
||||
.WithAuthor(GetText(strs.image_search_for) + " " + oterms.TrimTo(50),
|
||||
"http://s.imgur.com/images/logo-1200-630.jpg?",
|
||||
fullQueryLink)
|
||||
.WithDescription(source)
|
||||
.WithImageUrl(source)
|
||||
.WithTitle(ctx.User.ToString());
|
||||
await ctx.Channel.EmbedAsync(embed);
|
||||
}
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
public async partial Task Lmgtfy([Leftover] string ffs = null)
|
||||
{
|
||||
if (!await ValidateQuery(ffs))
|
||||
return;
|
||||
|
||||
var shortenedUrl = await _google.ShortenUrl($"http://lmgtfy.com/?q={Uri.EscapeDataString(ffs)}");
|
||||
var shortenedUrl = await _google.ShortenUrl($"https://lmgtfy.com/?q={Uri.EscapeDataString(ffs)}");
|
||||
await SendConfirmAsync($"<{shortenedUrl}>");
|
||||
}
|
||||
|
||||
@@ -317,69 +245,6 @@ public partial class Searches : NadekoModule<SearchesService>
|
||||
.AddField(GetText(strs.short_url), $"<{shortLink}>"));
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
public async partial Task Google([Leftover] string query = null)
|
||||
{
|
||||
query = query?.Trim();
|
||||
if (!await ValidateQuery(query))
|
||||
return;
|
||||
|
||||
_ = ctx.Channel.TriggerTypingAsync();
|
||||
|
||||
var data = await _service.GoogleSearchAsync(query);
|
||||
if (data is null)
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.no_results);
|
||||
return;
|
||||
}
|
||||
|
||||
var desc = data.Results.Take(5)
|
||||
.Select(res => $@"[**{res.Title}**]({res.Link})
|
||||
{res.Text.TrimTo(400 - res.Title.Length - res.Link.Length)}");
|
||||
|
||||
var descStr = string.Join("\n\n", desc);
|
||||
|
||||
var embed = _eb.Create()
|
||||
.WithAuthor(ctx.User.ToString(), "http://i.imgur.com/G46fm8J.png")
|
||||
.WithTitle(ctx.User.ToString())
|
||||
.WithFooter(data.TotalResults)
|
||||
.WithDescription($"{GetText(strs.search_for)} **{query}**\n\n" + descStr)
|
||||
.WithOkColor();
|
||||
|
||||
await ctx.Channel.EmbedAsync(embed);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
public async partial Task DuckDuckGo([Leftover] string query = null)
|
||||
{
|
||||
query = query?.Trim();
|
||||
if (!await ValidateQuery(query))
|
||||
return;
|
||||
|
||||
_ = ctx.Channel.TriggerTypingAsync();
|
||||
|
||||
var data = await _service.DuckDuckGoSearchAsync(query);
|
||||
if (data is null)
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.no_results);
|
||||
return;
|
||||
}
|
||||
|
||||
var desc = data.Results.Take(5)
|
||||
.Select(res => $@"[**{res.Title}**]({res.Link})
|
||||
{res.Text.TrimTo(380 - res.Title.Length - res.Link.Length)}");
|
||||
|
||||
var descStr = string.Join("\n\n", desc);
|
||||
|
||||
var embed = _eb.Create()
|
||||
.WithAuthor(ctx.User.ToString(),
|
||||
"https://upload.wikimedia.org/wikipedia/en/9/90/The_DuckDuckGo_Duck.png")
|
||||
.WithDescription($"{GetText(strs.search_for)} **{query}**\n\n" + descStr)
|
||||
.WithOkColor();
|
||||
|
||||
await ctx.Channel.EmbedAsync(embed);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
public async partial Task MagicTheGathering([Leftover] string search)
|
||||
{
|
||||
@@ -446,7 +311,7 @@ public partial class Searches : NadekoModule<SearchesService>
|
||||
using (var http = _httpFactory.CreateClient())
|
||||
{
|
||||
var res = await http.GetStringAsync(
|
||||
$"http://api.urbandictionary.com/v0/define?term={Uri.EscapeDataString(query)}");
|
||||
$"https://api.urbandictionary.com/v0/define?term={Uri.EscapeDataString(query)}");
|
||||
try
|
||||
{
|
||||
var items = JsonConvert.DeserializeObject<UrbanResponse>(res).List;
|
||||
@@ -732,7 +597,7 @@ public partial class Searches : NadekoModule<SearchesService>
|
||||
await ctx.Channel.SendMessageAsync($"https://store.steampowered.com/app/{appId}");
|
||||
}
|
||||
|
||||
private async Task<bool> ValidateQuery(string query)
|
||||
private async Task<bool> ValidateQuery([MaybeNullWhen(false)] string query)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(query))
|
||||
return true;
|
||||
|
@@ -26,15 +26,6 @@ public class SearchesService : INService
|
||||
Birds
|
||||
}
|
||||
|
||||
private static readonly HtmlParser _googleParser = new(new()
|
||||
{
|
||||
IsScripting = false,
|
||||
IsEmbedded = false,
|
||||
IsSupportingProcessingInstructions = false,
|
||||
IsKeepingSourceReferences = false,
|
||||
IsNotSupportingFrames = true
|
||||
});
|
||||
|
||||
public List<WoWJoke> WowJokes { get; } = new();
|
||||
public List<MagicItem> MagicItems { get; } = new();
|
||||
private readonly IHttpClientFactory _httpFactory;
|
||||
@@ -161,7 +152,7 @@ public class SearchesService : INService
|
||||
using var http = _httpFactory.CreateClient();
|
||||
try
|
||||
{
|
||||
var data = await http.GetStringAsync("http://api.openweathermap.org/data/2.5/weather?"
|
||||
var data = await http.GetStringAsync("https://api.openweathermap.org/data/2.5/weather?"
|
||||
+ $"q={query}&"
|
||||
+ "appid=42cd627dd60debf25a5739e50a217d74&"
|
||||
+ "units=metric");
|
||||
@@ -440,22 +431,6 @@ public class SearchesService : INService
|
||||
public async Task<int> GetSteamAppIdByName(string query)
|
||||
{
|
||||
const string steamGameIdsKey = "steam_names_to_appid";
|
||||
// var exists = await db.KeyExistsAsync(steamGameIdsKey);
|
||||
|
||||
// if we didn't get steam name to id map already, get it
|
||||
//if (!exists)
|
||||
//{
|
||||
// using (var http = _httpFactory.CreateClient())
|
||||
// {
|
||||
// // https://api.steampowered.com/ISteamApps/GetAppList/v2/
|
||||
// var gamesStr = await http.GetStringAsync("https://api.steampowered.com/ISteamApps/GetAppList/v2/");
|
||||
// var apps = JsonConvert.DeserializeAnonymousType(gamesStr, new { applist = new { apps = new List<SteamGameId>() } }).applist.apps;
|
||||
|
||||
// //await db.HashSetAsync("steam_game_ids", apps.Select(app => new HashEntry(app.Name.Trim().ToLowerInvariant(), app.AppId)).ToArray());
|
||||
// await db.StringSetAsync("steam_game_ids", gamesStr, TimeSpan.FromHours(24));
|
||||
// //await db.KeyExpireAsync("steam_game_ids", TimeSpan.FromHours(24), CommandFlags.FireAndForget);
|
||||
// }
|
||||
//}
|
||||
|
||||
var gamesMap = await _cache.GetOrAddCachedDataAsync(steamGameIdsKey,
|
||||
async _ =>
|
||||
@@ -502,150 +477,5 @@ public class SearchesService : INService
|
||||
}
|
||||
|
||||
return gamesMap[key];
|
||||
|
||||
|
||||
//// try finding the game id
|
||||
//var val = db.HashGet(STEAM_GAME_IDS_KEY, query);
|
||||
//if (val == default)
|
||||
// return -1; // not found
|
||||
|
||||
//var appid = (int)val;
|
||||
//return appid;
|
||||
|
||||
// now that we have appid, get the game info with that appid
|
||||
//var gameData = await _cache.GetOrAddCachedDataAsync($"steam_game:{appid}", SteamGameDataFactory, appid, TimeSpan.FromHours(12))
|
||||
//;
|
||||
|
||||
//return gameData;
|
||||
}
|
||||
|
||||
public async Task<GoogleSearchResultData> GoogleSearchAsync(string query)
|
||||
{
|
||||
query = WebUtility.UrlEncode(query)?.Replace(' ', '+');
|
||||
|
||||
var fullQueryLink = $"https://www.google.ca/search?q={query}&safe=on&lr=lang_eng&hl=en&ie=utf-8&oe=utf-8";
|
||||
|
||||
using var msg = new HttpRequestMessage(HttpMethod.Get, fullQueryLink);
|
||||
msg.Headers.Add("User-Agent",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.77 Safari/537.36");
|
||||
msg.Headers.Add("Cookie", "CONSENT=YES+shp.gws-20210601-0-RC2.en+FX+423;");
|
||||
|
||||
using var http = _httpFactory.CreateClient();
|
||||
http.DefaultRequestHeaders.Clear();
|
||||
|
||||
using var response = await http.SendAsync(msg);
|
||||
await using var content = await response.Content.ReadAsStreamAsync();
|
||||
|
||||
using var document = await _googleParser.ParseDocumentAsync(content);
|
||||
var elems = document.QuerySelectorAll("div.g > div > div");
|
||||
|
||||
var resultsElem = document.QuerySelectorAll("#resultStats").FirstOrDefault();
|
||||
var totalResults = resultsElem?.TextContent;
|
||||
//var time = resultsElem.Children.FirstOrDefault()?.TextContent
|
||||
//^ this doesn't work for some reason, <nobr> is completely missing in parsed collection
|
||||
if (!elems.Any())
|
||||
return default;
|
||||
|
||||
var results = elems.Select(elem =>
|
||||
{
|
||||
var children = elem.Children.ToList();
|
||||
if (children.Count < 2)
|
||||
return null;
|
||||
|
||||
var href = (children[0].QuerySelector("a") as IHtmlAnchorElement)?.Href;
|
||||
var name = children[0].QuerySelector("h3")?.TextContent;
|
||||
|
||||
if (href is null || name is null)
|
||||
return null;
|
||||
|
||||
var txt = children[1].TextContent;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(txt))
|
||||
return null;
|
||||
|
||||
return new GoogleSearchResult(name, href, txt);
|
||||
})
|
||||
.Where(x => x is not null)
|
||||
.ToList();
|
||||
|
||||
return new(results.AsReadOnly(), fullQueryLink, totalResults);
|
||||
}
|
||||
|
||||
public async Task<GoogleSearchResultData> DuckDuckGoSearchAsync(string query)
|
||||
{
|
||||
query = WebUtility.UrlEncode(query)?.Replace(' ', '+');
|
||||
|
||||
var fullQueryLink = "https://html.duckduckgo.com/html";
|
||||
|
||||
using var http = _httpFactory.CreateClient();
|
||||
http.DefaultRequestHeaders.Clear();
|
||||
http.DefaultRequestHeaders.Add("User-Agent",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.77 Safari/537.36");
|
||||
|
||||
using var formData = new MultipartFormDataContent();
|
||||
formData.Add(new StringContent(query), "q");
|
||||
using var response = await http.PostAsync(fullQueryLink, formData);
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
|
||||
using var document = await _googleParser.ParseDocumentAsync(content);
|
||||
var searchResults = document.QuerySelector(".results");
|
||||
var elems = searchResults.QuerySelectorAll(".result");
|
||||
|
||||
if (!elems.Any())
|
||||
return default;
|
||||
|
||||
var results = elems.Select(elem =>
|
||||
{
|
||||
if (elem.QuerySelector(".result__a") is not IHtmlAnchorElement anchor)
|
||||
return null;
|
||||
|
||||
var href = anchor.Href;
|
||||
var name = anchor.TextContent;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(href) || string.IsNullOrWhiteSpace(name))
|
||||
return null;
|
||||
|
||||
var txt = elem.QuerySelector(".result__snippet")?.TextContent;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(txt))
|
||||
return null;
|
||||
|
||||
return new GoogleSearchResult(name, href, txt);
|
||||
})
|
||||
.Where(x => x is not null)
|
||||
.ToList();
|
||||
|
||||
return new(results.AsReadOnly(), fullQueryLink, "0");
|
||||
}
|
||||
|
||||
//private async Task<SteamGameData> SteamGameDataFactory(int appid)
|
||||
//{
|
||||
// using (var http = _httpFactory.CreateClient())
|
||||
// {
|
||||
// // https://store.steampowered.com/api/appdetails?appids=
|
||||
// var responseStr = await http.GetStringAsync($"https://store.steampowered.com/api/appdetails?appids={appid}");
|
||||
// var data = JsonConvert.DeserializeObject<Dictionary<int, SteamGameData.Container>>(responseStr);
|
||||
// if (!data.ContainsKey(appid) || !data[appid].Success)
|
||||
// return null; // for some reason we can't get the game with valid appid. SHould never happen
|
||||
|
||||
// return data[appid].Data;
|
||||
// }
|
||||
//}
|
||||
|
||||
public class GoogleSearchResultData
|
||||
{
|
||||
public IReadOnlyList<GoogleSearchResult> Results { get; }
|
||||
public string FullQueryLink { get; }
|
||||
public string TotalResults { get; }
|
||||
|
||||
public GoogleSearchResultData(
|
||||
IReadOnlyList<GoogleSearchResult> results,
|
||||
string fullQueryLink,
|
||||
string totalResults)
|
||||
{
|
||||
Results = results;
|
||||
FullQueryLink = fullQueryLink;
|
||||
TotalResults = totalResults;
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,6 +1,4 @@
|
||||
#nullable disable
|
||||
using LinqToDB;
|
||||
using LinqToDB.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NadekoBot.Common.ModuleBehaviors;
|
||||
using NadekoBot.Db;
|
||||
|
@@ -0,0 +1,7 @@
|
||||
namespace NadekoBot.Modules.Searches;
|
||||
|
||||
public enum ImgSearchEngine
|
||||
{
|
||||
Google,
|
||||
Searx,
|
||||
}
|
@@ -0,0 +1,63 @@
|
||||
using Cloneable;
|
||||
using NadekoBot.Common.Yml;
|
||||
|
||||
namespace NadekoBot.Modules.Searches;
|
||||
|
||||
[Cloneable]
|
||||
public partial class SearchesConfig : ICloneable<SearchesConfig>
|
||||
{
|
||||
[Comment(@"Which engine should .search command
|
||||
'google' requires googleApiKey and google.searchId set in creds.yml
|
||||
'searx' requires at least one searx instance specified in the 'searxInstances' property below")]
|
||||
public WebSearchEngine WebSearchEngine { get; set; } = WebSearchEngine.Google;
|
||||
|
||||
[Comment(@"Which engine should .image command use
|
||||
'google' requires googleApiKey and google.imageSearchId set in creds.yml
|
||||
'searx' requires at least one searx instance specified in the 'searxInstances' property below")]
|
||||
public ImgSearchEngine ImgSearchEngine { get; set; } = ImgSearchEngine.Google;
|
||||
|
||||
|
||||
[Comment(@"Which search provider will be used for the `.youtube` command.
|
||||
|
||||
- `ytDataApiv3` - uses google's official youtube data api. Requires `GoogleApiKey` set in creds and youtube data api enabled in developers console
|
||||
|
||||
- `ytdl` - default, uses youtube-dl. Requires `youtube-dl` to be installed and it's path added to env variables. Slow.
|
||||
|
||||
- `ytdlp` - recommended easy, uses `yt-dlp`. Requires `yt-dlp` to be installed and it's path added to env variables
|
||||
|
||||
- `invidious` - recommended advanced, uses invidious api. Requires at least one invidious instance specified in the `invidiousInstances` property")]
|
||||
public YoutubeSearcher YtProvider { get; set; } = YoutubeSearcher.Ytdl;
|
||||
|
||||
[Comment(@"Set the searx instance urls in case you want to use 'searx' for either img or web search.
|
||||
Nadeko will use a random one for each request.
|
||||
Use a fully qualified url. Example: `https://my-searx-instance.mydomain.com`
|
||||
Instances specified must support 'format=json' query parameter.
|
||||
- In case you're running your own searx instance, set
|
||||
|
||||
search:
|
||||
formats:
|
||||
- json
|
||||
|
||||
in 'searxng/settings.yml' on your server
|
||||
|
||||
- If you're using a public instance, make sure that the instance you're using supports it (they usually don't)")]
|
||||
public List<string> SearxInstances { get; set; } = new List<string>();
|
||||
|
||||
[Comment(@"Set the invidious instance urls in case you want to use 'invidious' for `.youtube` search
|
||||
Nadeko will use a random one for each request.
|
||||
These instances may be used for music queue functionality in the future.
|
||||
Use a fully qualified url. Example: https://my-invidious-instance.mydomain.com
|
||||
|
||||
Instances specified must have api available.
|
||||
You check that by opening an api endpoint in your browser. For example: https://my-invidious-instance.mydomain.com/api/v1/trending")]
|
||||
public List<string> InvidiousInstances { get; set; } = new List<string>();
|
||||
}
|
||||
|
||||
public enum YoutubeSearcher
|
||||
{
|
||||
YtDataApiv3,
|
||||
Ytdl,
|
||||
Ytdlp,
|
||||
Invid,
|
||||
Invidious = 3
|
||||
}
|
@@ -0,0 +1,31 @@
|
||||
using NadekoBot.Common.Configs;
|
||||
|
||||
namespace NadekoBot.Modules.Searches;
|
||||
|
||||
public class SearchesConfigService : ConfigServiceBase<SearchesConfig>
|
||||
{
|
||||
private static string FILE_PATH = "data/searches.yml";
|
||||
private static readonly TypedKey<SearchesConfig> _changeKey = new("config.searches.updated");
|
||||
|
||||
public override string Name
|
||||
=> "searches";
|
||||
|
||||
public SearchesConfigService(IConfigSeria serializer, IPubSub pubSub)
|
||||
: base(FILE_PATH, serializer, pubSub, _changeKey)
|
||||
{
|
||||
AddParsedProp("webEngine",
|
||||
sc => sc.WebSearchEngine,
|
||||
ConfigParsers.InsensitiveEnum,
|
||||
ConfigPrinters.ToString);
|
||||
|
||||
AddParsedProp("imgEngine",
|
||||
sc => sc.ImgSearchEngine,
|
||||
ConfigParsers.InsensitiveEnum,
|
||||
ConfigPrinters.ToString);
|
||||
|
||||
AddParsedProp("ytProvider",
|
||||
sc => sc.YtProvider,
|
||||
ConfigParsers.InsensitiveEnum,
|
||||
ConfigPrinters.ToString);
|
||||
}
|
||||
}
|
@@ -0,0 +1,7 @@
|
||||
namespace NadekoBot.Modules.Searches;
|
||||
|
||||
public enum WebSearchEngine
|
||||
{
|
||||
Google,
|
||||
Searx,
|
||||
}
|
@@ -1,4 +1,5 @@
|
||||
#nullable disable
|
||||
using NadekoBot.Modules.Utility.Patronage;
|
||||
using System.Text;
|
||||
|
||||
namespace NadekoBot.Modules.Utility;
|
||||
@@ -10,11 +11,13 @@ public partial class Utility
|
||||
{
|
||||
private readonly DiscordSocketClient _client;
|
||||
private readonly IStatsService _stats;
|
||||
private readonly IPatronageService _ps;
|
||||
|
||||
public InfoCommands(DiscordSocketClient client, IStatsService stats)
|
||||
public InfoCommands(DiscordSocketClient client, IStatsService stats, IPatronageService ps)
|
||||
{
|
||||
_client = client;
|
||||
_stats = stats;
|
||||
_ps = ps;
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
@@ -106,6 +109,7 @@ public partial class Utility
|
||||
var embed = _eb.Create().AddField(GetText(strs.name), $"**{user.Username}**#{user.Discriminator}", true);
|
||||
if (!string.IsNullOrWhiteSpace(user.Nickname))
|
||||
embed.AddField(GetText(strs.nickname), user.Nickname, true);
|
||||
|
||||
embed.AddField(GetText(strs.id), user.Id.ToString(), true)
|
||||
.AddField(GetText(strs.joined_server), $"{user.JoinedAt?.ToString("dd.MM.yyyy HH:mm") ?? "?"}", true)
|
||||
.AddField(GetText(strs.joined_discord), $"{user.CreatedAt:dd.MM.yyyy HH:mm}", true)
|
||||
@@ -114,9 +118,24 @@ public partial class Utility
|
||||
true)
|
||||
.WithOkColor();
|
||||
|
||||
var patron = await _ps.GetPatronAsync(user.Id);
|
||||
|
||||
if (patron.Tier != PatronTier.None)
|
||||
{
|
||||
embed.WithFooter(patron.Tier switch
|
||||
{
|
||||
PatronTier.V => "❤️❤️",
|
||||
PatronTier.X => "❤️❤️❤️",
|
||||
PatronTier.XX => "❤️❤️❤️❤️",
|
||||
PatronTier.L => "❤️❤️❤️❤️❤️",
|
||||
_ => "❤️",
|
||||
});
|
||||
}
|
||||
|
||||
var av = user.RealAvatarUrl();
|
||||
if (av.IsAbsoluteUri)
|
||||
embed.WithThumbnailUrl(av.ToString());
|
||||
|
||||
await ctx.Channel.EmbedAsync(embed);
|
||||
}
|
||||
|
||||
|
@@ -1,305 +0,0 @@
|
||||
#nullable disable
|
||||
using LinqToDB.EntityFrameworkCore;
|
||||
using NadekoBot.Common.ModuleBehaviors;
|
||||
using NadekoBot.Modules.Gambling.Services;
|
||||
using NadekoBot.Modules.Utility.Common.Patreon;
|
||||
using NadekoBot.Services.Database.Models;
|
||||
using StackExchange.Redis;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace NadekoBot.Modules.Utility;
|
||||
|
||||
public class PatreonRewardsService : INService, IReadyExecutor
|
||||
{
|
||||
public TimeSpan Interval { get; } = TimeSpan.FromMinutes(3);
|
||||
|
||||
public DateTime LastUpdate { get; private set; } = DateTime.UtcNow;
|
||||
|
||||
private readonly SemaphoreSlim _claimLockJustInCase = new(1, 1);
|
||||
private readonly DbService _db;
|
||||
private readonly ICurrencyService _currency;
|
||||
private readonly GamblingConfigService _gamblingConfigService;
|
||||
private readonly ConnectionMultiplexer _redis;
|
||||
private readonly IBotCredsProvider _credsProvider;
|
||||
private readonly IHttpClientFactory _httpFactory;
|
||||
private readonly IEmbedBuilderService _eb;
|
||||
private readonly DiscordSocketClient _client;
|
||||
|
||||
public PatreonRewardsService(
|
||||
DbService db,
|
||||
ICurrencyService currency,
|
||||
IHttpClientFactory factory,
|
||||
IEmbedBuilderService eb,
|
||||
DiscordSocketClient client,
|
||||
GamblingConfigService gamblingConfigService,
|
||||
ConnectionMultiplexer redis,
|
||||
IBotCredsProvider credsProvider)
|
||||
{
|
||||
_db = db;
|
||||
_currency = currency;
|
||||
_gamblingConfigService = gamblingConfigService;
|
||||
_redis = redis;
|
||||
_credsProvider = credsProvider;
|
||||
_httpFactory = factory;
|
||||
_eb = eb;
|
||||
_client = client;
|
||||
}
|
||||
|
||||
public async Task OnReadyAsync()
|
||||
{
|
||||
if (_client.ShardId != 0)
|
||||
return;
|
||||
|
||||
using var t = new PeriodicTimer(Interval);
|
||||
do
|
||||
{
|
||||
try
|
||||
{
|
||||
await RefreshPledges(_credsProvider.GetCreds());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Unexpected error refreshing patreon pledges: {ErrorMessage}", ex.Message);
|
||||
}
|
||||
} while (await t.WaitForNextTickAsync());
|
||||
}
|
||||
|
||||
private DateTime LastAccessTokenUpdate(IBotCredentials creds)
|
||||
{
|
||||
var db = _redis.GetDatabase();
|
||||
var val = db.StringGet($"{creds.RedisKey()}_patreon_update");
|
||||
|
||||
if (val == default)
|
||||
return DateTime.MinValue;
|
||||
|
||||
var lastTime = DateTime.FromBinary((long)val);
|
||||
return lastTime;
|
||||
}
|
||||
|
||||
private async Task<bool> UpdateAccessToken(IBotCredentials creds)
|
||||
{
|
||||
Log.Information("Updating patreon access token...");
|
||||
try
|
||||
{
|
||||
using var http = _httpFactory.CreateClient();
|
||||
using var res = await http.PostAsync("https://www.patreon.com/api/oauth2/token"
|
||||
+ "?grant_type=refresh_token"
|
||||
+ $"&refresh_token={creds.Patreon.RefreshToken}"
|
||||
+ $"&client_id={creds.Patreon.ClientId}"
|
||||
+ $"&client_secret={creds.Patreon.ClientSecret}",
|
||||
null);
|
||||
|
||||
res.EnsureSuccessStatusCode();
|
||||
|
||||
var data = await res.Content.ReadFromJsonAsync<PatreonRefreshData>();
|
||||
|
||||
if (data is null)
|
||||
throw new("Invalid patreon response.");
|
||||
|
||||
_credsProvider.ModifyCredsFile(oldData =>
|
||||
{
|
||||
oldData.Patreon.AccessToken = data.AccessToken;
|
||||
oldData.Patreon.RefreshToken = data.RefreshToken;
|
||||
});
|
||||
|
||||
var db = _redis.GetDatabase();
|
||||
await db.StringSetAsync($"{creds.RedisKey()}_patreon_update", DateTime.UtcNow.ToBinary());
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error("Failed updating patreon access token: {ErrorMessage}", ex.ToString());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private bool HasPatreonCreds(IBotCredentials creds)
|
||||
{
|
||||
var cid = creds.Patreon.ClientId;
|
||||
var cs = creds.Patreon.ClientSecret;
|
||||
var rt = creds.Patreon.RefreshToken;
|
||||
return !(string.IsNullOrWhiteSpace(cid) || string.IsNullOrWhiteSpace(cs) || string.IsNullOrWhiteSpace(rt));
|
||||
}
|
||||
|
||||
public async Task RefreshPledges(IBotCredentials creds)
|
||||
{
|
||||
if (DateTime.UtcNow.Day < 5)
|
||||
return;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(creds.Patreon.CampaignId))
|
||||
return;
|
||||
|
||||
var lastUpdate = LastAccessTokenUpdate(creds);
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
if (lastUpdate.Year != now.Year
|
||||
|| lastUpdate.Month != now.Month
|
||||
|| string.IsNullOrWhiteSpace(creds.Patreon.AccessToken))
|
||||
{
|
||||
// if the user has the necessary patreon creds
|
||||
// and the access token expired or doesn't exist
|
||||
// -> update access token
|
||||
if (!HasPatreonCreds(creds))
|
||||
return;
|
||||
|
||||
var success = await UpdateAccessToken(creds);
|
||||
if (!success)
|
||||
return;
|
||||
}
|
||||
|
||||
LastUpdate = DateTime.UtcNow;
|
||||
try
|
||||
{
|
||||
var members = new List<PatreonMember>();
|
||||
var users = new List<PatreonUser>();
|
||||
using (var http = _httpFactory.CreateClient())
|
||||
{
|
||||
http.DefaultRequestHeaders.Clear();
|
||||
http.DefaultRequestHeaders.TryAddWithoutValidation("Authorization",
|
||||
$"Bearer {creds.Patreon.AccessToken}");
|
||||
|
||||
var page = $"https://www.patreon.com/api/oauth2/v2/campaigns/{creds.Patreon.CampaignId}/members"
|
||||
+ "?fields%5Bmember%5D=full_name,currently_entitled_amount_cents"
|
||||
+ "&fields%5Buser%5D=social_connections"
|
||||
+ "&include=user";
|
||||
PatreonResponse data;
|
||||
do
|
||||
{
|
||||
var res = await http.GetStringAsync(page);
|
||||
data = JsonSerializer.Deserialize<PatreonResponse>(res);
|
||||
|
||||
if (data is null)
|
||||
break;
|
||||
|
||||
members.AddRange(data.Data);
|
||||
users.AddRange(data.Included);
|
||||
} while (!string.IsNullOrWhiteSpace(page = data.Links?.Next));
|
||||
}
|
||||
|
||||
var userData = members.Join(users,
|
||||
m => m.Relationships.User.Data.Id,
|
||||
u => u.Id,
|
||||
(m, u) => new
|
||||
{
|
||||
PatreonUserId = m.Relationships.User.Data.Id,
|
||||
UserId = ulong.TryParse(
|
||||
u.Attributes?.SocialConnections?.Discord?.UserId ?? string.Empty,
|
||||
out var userId)
|
||||
? userId
|
||||
: 0,
|
||||
EntitledTo = m.Attributes.CurrentlyEntitledAmountCents
|
||||
})
|
||||
.Where(x => x is
|
||||
{
|
||||
UserId: not 0,
|
||||
EntitledTo: > 0
|
||||
})
|
||||
.ToList();
|
||||
|
||||
foreach (var pledge in userData)
|
||||
await ClaimReward(pledge.UserId, pledge.PatreonUserId, pledge.EntitledTo);
|
||||
}
|
||||
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.Unauthorized)
|
||||
{
|
||||
Log.Warning("Patreon credentials invalid or expired. I will try to refresh them during the next run");
|
||||
var db = _redis.GetDatabase();
|
||||
await db.KeyDeleteAsync($"{creds.RedisKey()}_patreon_update");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Error refreshing patreon pledges");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<int> ClaimReward(ulong userId, string patreonUserId, int cents)
|
||||
{
|
||||
await _claimLockJustInCase.WaitAsync();
|
||||
var settings = _gamblingConfigService.Data;
|
||||
var now = DateTime.UtcNow;
|
||||
try
|
||||
{
|
||||
var eligibleFor = (int)(cents * settings.PatreonCurrencyPerCent);
|
||||
|
||||
await using var uow = _db.GetDbContext();
|
||||
var users = uow.Set<RewardedUser>();
|
||||
var usr = await users.FirstOrDefaultAsyncEF(x => x.PatreonUserId == patreonUserId);
|
||||
|
||||
if (usr is null)
|
||||
{
|
||||
users.Add(new()
|
||||
{
|
||||
PatreonUserId = patreonUserId,
|
||||
LastReward = now,
|
||||
AmountRewardedThisMonth = eligibleFor
|
||||
});
|
||||
|
||||
await uow.SaveChangesAsync();
|
||||
|
||||
await _currency.AddAsync(userId, eligibleFor, new("patreon", "new"));
|
||||
|
||||
Log.Information("Sending new currency reward to {UserId}", userId);
|
||||
await SendMessageToUser(userId,
|
||||
"Thank you for your pledge! " + $"You've been awarded **{eligibleFor}**{settings.Currency.Sign} !");
|
||||
return eligibleFor;
|
||||
}
|
||||
|
||||
if (usr.LastReward.Month != now.Month)
|
||||
{
|
||||
usr.LastReward = now;
|
||||
usr.AmountRewardedThisMonth = eligibleFor;
|
||||
|
||||
await uow.SaveChangesAsync();
|
||||
|
||||
await _currency.AddAsync(userId, eligibleFor, new("patreon", "recurring"));
|
||||
|
||||
Log.Information("Sending recurring currency reward to {UserId}", userId);
|
||||
await SendMessageToUser(userId,
|
||||
"Thank you for your continued support! "
|
||||
+ $"You've been awarded **{eligibleFor}**{settings.Currency.Sign} for this month's support!");
|
||||
|
||||
return eligibleFor;
|
||||
}
|
||||
|
||||
if (usr.AmountRewardedThisMonth < eligibleFor)
|
||||
{
|
||||
var toAward = eligibleFor - usr.AmountRewardedThisMonth;
|
||||
|
||||
usr.LastReward = now;
|
||||
usr.AmountRewardedThisMonth = eligibleFor;
|
||||
await uow.SaveChangesAsync();
|
||||
|
||||
await _currency.AddAsync(userId, toAward, new("patreon", "update"));
|
||||
|
||||
Log.Information("Sending updated currency reward to {UserId}", userId);
|
||||
await SendMessageToUser(userId,
|
||||
"Thank you for increasing your pledge! "
|
||||
+ $"You've been awarded an additional **{toAward}**{settings.Currency.Sign} !");
|
||||
return toAward;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_claimLockJustInCase.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SendMessageToUser(ulong userId, string message)
|
||||
{
|
||||
try
|
||||
{
|
||||
var user = (IUser)_client.GetUser(userId) ?? await _client.Rest.GetUserAsync(userId);
|
||||
if (user is null)
|
||||
return;
|
||||
|
||||
await user.SendConfirmAsync(_eb, message);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignored
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,37 @@
|
||||
using NadekoBot.Common.Yml;
|
||||
using Cloneable;
|
||||
|
||||
namespace NadekoBot.Modules.Utility.Patronage;
|
||||
|
||||
[Cloneable]
|
||||
public partial class PatronConfigData : ICloneable<PatronConfigData>
|
||||
{
|
||||
[Comment("DO NOT CHANGE")]
|
||||
public int Version { get; set; } = 1;
|
||||
|
||||
[Comment("Whether the patronage feature is enabled")]
|
||||
public bool IsEnabled { get; set; }
|
||||
|
||||
[Comment("List of patron only features and relevant quota data")]
|
||||
public FeatureQuotas Quotas { get; set; }
|
||||
|
||||
public PatronConfigData()
|
||||
{
|
||||
Quotas = new();
|
||||
}
|
||||
|
||||
public class FeatureQuotas
|
||||
{
|
||||
[Comment("Dictionary of feature names with their respective limits. Set to null for unlimited")]
|
||||
public Dictionary<string, Dictionary<PatronTier, int?>> Features { get; set; } = new();
|
||||
|
||||
[Comment("Dictionary of commands with their respective quota data")]
|
||||
public Dictionary<string, Dictionary<PatronTier, Dictionary<QuotaPer, uint>?>> Commands { get; set; } = new();
|
||||
|
||||
[Comment("Dictionary of groups with their respective quota data")]
|
||||
public Dictionary<string, Dictionary<PatronTier, Dictionary<QuotaPer, uint>?>> Groups { get; set; } = new();
|
||||
|
||||
[Comment("Dictionary of modules with their respective quota data")]
|
||||
public Dictionary<string, Dictionary<PatronTier, Dictionary<QuotaPer, uint>?>> Modules { get; set; } = new();
|
||||
}
|
||||
}
|
14
src/NadekoBot/Modules/Utility/Patronage/Config/PatronTier.cs
Normal file
14
src/NadekoBot/Modules/Utility/Patronage/Config/PatronTier.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
// ReSharper disable InconsistentNaming
|
||||
namespace NadekoBot.Modules.Utility.Patronage;
|
||||
|
||||
public enum PatronTier
|
||||
{
|
||||
None,
|
||||
I,
|
||||
V,
|
||||
X,
|
||||
XX,
|
||||
L,
|
||||
C,
|
||||
ComingSoon
|
||||
}
|
@@ -0,0 +1,18 @@
|
||||
using NadekoBot.Common.Configs;
|
||||
|
||||
namespace NadekoBot.Modules.Utility.Patronage;
|
||||
|
||||
public class PatronageConfig : ConfigServiceBase<PatronConfigData>
|
||||
{
|
||||
public override string Name
|
||||
=> "patron";
|
||||
|
||||
private static readonly TypedKey<PatronConfigData> _changeKey
|
||||
= new TypedKey<PatronConfigData>("config.patron.updated");
|
||||
|
||||
private const string FILE_PATH = "data/patron.yml";
|
||||
|
||||
public PatronageConfig(IConfigSeria serializer, IPubSub pubSub) : base(FILE_PATH, serializer, pubSub, _changeKey)
|
||||
{
|
||||
}
|
||||
}
|
@@ -0,0 +1,8 @@
|
||||
namespace NadekoBot.Modules.Utility.Patronage;
|
||||
|
||||
public enum QuotaPer
|
||||
{
|
||||
PerHour,
|
||||
PerDay,
|
||||
PerMonth,
|
||||
}
|
190
src/NadekoBot/Modules/Utility/Patronage/CurrencyRewardService.cs
Normal file
190
src/NadekoBot/Modules/Utility/Patronage/CurrencyRewardService.cs
Normal file
@@ -0,0 +1,190 @@
|
||||
#nullable disable
|
||||
using LinqToDB;
|
||||
using LinqToDB.EntityFrameworkCore;
|
||||
using NadekoBot.Modules.Utility.Patronage;
|
||||
using NadekoBot.Modules.Gambling.Bank;
|
||||
using NadekoBot.Modules.Gambling.Services;
|
||||
using NadekoBot.Services.Currency;
|
||||
using NadekoBot.Services.Database.Models;
|
||||
|
||||
namespace NadekoBot.Modules.Utility;
|
||||
|
||||
public class CurrencyRewardService : INService, IDisposable
|
||||
{
|
||||
private readonly ICurrencyService _cs;
|
||||
private readonly IPatronageService _ps;
|
||||
private readonly DbService _db;
|
||||
private readonly IBankService _bs;
|
||||
private readonly IEmbedBuilderService _eb;
|
||||
private readonly GamblingConfigService _config;
|
||||
private readonly DiscordSocketClient _client;
|
||||
|
||||
public CurrencyRewardService(
|
||||
ICurrencyService cs,
|
||||
IPatronageService ps,
|
||||
DbService db,
|
||||
IBankService bs,
|
||||
IEmbedBuilderService eb,
|
||||
GamblingConfigService config,
|
||||
DiscordSocketClient client)
|
||||
{
|
||||
_cs = cs;
|
||||
_ps = ps;
|
||||
_db = db;
|
||||
_bs = bs;
|
||||
_eb = eb;
|
||||
_config = config;
|
||||
_client = client;
|
||||
|
||||
_ps.OnNewPatronPayment += OnNewPayment;
|
||||
_ps.OnPatronRefunded += OnPatronRefund;
|
||||
_ps.OnPatronUpdated += OnPatronUpdate;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_ps.OnNewPatronPayment -= OnNewPayment;
|
||||
_ps.OnPatronRefunded -= OnPatronRefund;
|
||||
_ps.OnPatronUpdated -= OnPatronUpdate;
|
||||
}
|
||||
|
||||
private async Task OnPatronUpdate(Patron oldPatron, Patron newPatron)
|
||||
{
|
||||
if (oldPatron.Amount != newPatron.Amount)
|
||||
{
|
||||
var conf = _config.Data;
|
||||
|
||||
var newAmount = (long)(Math.Max(newPatron.Amount, oldPatron.Amount) * conf.PatreonCurrencyPerCent);
|
||||
UpdateOutput<RewardedUser>[] output;
|
||||
await using (var ctx = _db.GetDbContext())
|
||||
{
|
||||
output = await ctx.GetTable<RewardedUser>()
|
||||
.Where(x => x.PlatformUserId == newPatron.UnqiuePlatformUserId)
|
||||
.UpdateWithOutputAsync(old => new()
|
||||
{
|
||||
PlatformUserId = newPatron.UnqiuePlatformUserId,
|
||||
UserId = newPatron.UserId,
|
||||
// amount before bonuses
|
||||
AmountRewardedThisMonth = newAmount,
|
||||
LastReward = newPatron.PaidAt
|
||||
});
|
||||
}
|
||||
|
||||
// if the user wasn't previously in the db for some reason,
|
||||
// we will treat him as a new patron
|
||||
if (output.Length == 0)
|
||||
{
|
||||
await OnNewPayment(newPatron);
|
||||
return;
|
||||
}
|
||||
|
||||
var oldAmount = output[0].Deleted.AmountRewardedThisMonth;
|
||||
|
||||
var diff = newAmount - oldAmount;
|
||||
if (diff <= 0)
|
||||
return; // no action if new is lower
|
||||
|
||||
// if the user pledges 5$ or more, they will get X % more flowers where X is amount in dollars,
|
||||
// up to 100%
|
||||
|
||||
var realAmount = GetRealCurrencyReward(newPatron.Amount, diff, out var percentBonus);
|
||||
await _cs.AddAsync(newPatron.UserId, realAmount, new TxData("patron","update"));
|
||||
|
||||
_ = SendMessageToUser(newPatron.UserId,
|
||||
$"You've received an additional **{realAmount}**{_config.Data.Currency.Sign} as a currency reward (+{percentBonus}%)!");
|
||||
}
|
||||
}
|
||||
|
||||
private long GetRealCurrencyReward(int fullPledge, long currentAmount, out int percentBonus)
|
||||
{
|
||||
// needs at least 5$ to be eligible for a bonus
|
||||
if (fullPledge < 500)
|
||||
{
|
||||
percentBonus = 0;
|
||||
return currentAmount;
|
||||
}
|
||||
|
||||
var dollarValue = fullPledge / 100;
|
||||
percentBonus = dollarValue switch
|
||||
{
|
||||
> 100 => 100,
|
||||
_ => dollarValue
|
||||
};
|
||||
return (long)(currentAmount * (1 + (percentBonus / 100.0f)));
|
||||
}
|
||||
|
||||
// on a new payment, always give the full amount.
|
||||
private async Task OnNewPayment(Patron patron)
|
||||
{
|
||||
var amount = (long)(patron.Amount * _config.Data.PatreonCurrencyPerCent);
|
||||
await using var ctx = _db.GetDbContext();
|
||||
await ctx.GetTable<RewardedUser>()
|
||||
.InsertOrUpdateAsync(() => new()
|
||||
{
|
||||
PlatformUserId = patron.UnqiuePlatformUserId,
|
||||
UserId = patron.UserId,
|
||||
AmountRewardedThisMonth = amount,
|
||||
LastReward = patron.PaidAt,
|
||||
},
|
||||
old => new()
|
||||
{
|
||||
AmountRewardedThisMonth = amount,
|
||||
UserId = patron.UserId,
|
||||
LastReward = patron.PaidAt
|
||||
},
|
||||
() => new()
|
||||
{
|
||||
PlatformUserId = patron.UnqiuePlatformUserId
|
||||
});
|
||||
|
||||
var realAmount = GetRealCurrencyReward(patron.Amount, amount, out var percentBonus);
|
||||
await _cs.AddAsync(patron.UserId, realAmount, new("patron", "new"));
|
||||
_ = SendMessageToUser(patron.UserId,
|
||||
$"You've received **{realAmount}**{_config.Data.Currency.Sign} as a currency reward (**+{percentBonus}%**)!");
|
||||
}
|
||||
|
||||
private async Task SendMessageToUser(ulong userId, string message)
|
||||
{
|
||||
try
|
||||
{
|
||||
var user = (IUser)_client.GetUser(userId) ?? await _client.Rest.GetUserAsync(userId);
|
||||
if (user is null)
|
||||
return;
|
||||
|
||||
var eb = _eb.Create()
|
||||
.WithOkColor()
|
||||
.WithDescription(message);
|
||||
|
||||
await user.EmbedAsync(eb);
|
||||
}
|
||||
catch
|
||||
{
|
||||
Log.Warning("Unable to send a \"Currency Reward\" message to the patron {UserId}", userId);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OnPatronRefund(Patron patron)
|
||||
{
|
||||
await using var ctx = _db.GetDbContext();
|
||||
_ = await ctx.GetTable<RewardedUser>()
|
||||
.UpdateWithOutputAsync(old => new()
|
||||
{
|
||||
AmountRewardedThisMonth = old.AmountRewardedThisMonth * 2
|
||||
});
|
||||
|
||||
// var toTake = old.Length == 0
|
||||
// ? patron.Amount
|
||||
// : old[0].Inserted.AmountRewardedThisMonth;
|
||||
|
||||
// if (toTake > 0)
|
||||
// {
|
||||
// Log.Warning("Wiping the wallet and bank of the user {UserId} due to a refund/fraud...",
|
||||
// patron.UserId);
|
||||
// await _cs.RemoveAsync(patron.UserId, patron.Amount, new("patreon", "refund"));
|
||||
// await _bs.BurnAllAsync(patron.UserId);
|
||||
// Log.Warning("Burned {Amount} currency from the bank of the user {UserId} due to a refund/fraud.",
|
||||
// patron.Amount,
|
||||
// patron.UserId);
|
||||
// }
|
||||
}
|
||||
}
|
@@ -0,0 +1,8 @@
|
||||
namespace NadekoBot.Modules.Utility.Patronage;
|
||||
|
||||
public readonly struct FeatureQuotaStats
|
||||
{
|
||||
public (uint Cur, uint Max) Hourly { get; init; }
|
||||
public (uint Cur, uint Max) Daily { get; init; }
|
||||
public (uint Cur, uint Max) Monthly { get; init; }
|
||||
}
|
11
src/NadekoBot/Modules/Utility/Patronage/InsufficientTier.cs
Normal file
11
src/NadekoBot/Modules/Utility/Patronage/InsufficientTier.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
using NadekoBot.Db.Models;
|
||||
|
||||
namespace NadekoBot.Modules.Utility.Patronage;
|
||||
|
||||
public readonly struct InsufficientTier
|
||||
{
|
||||
public FeatureType FeatureType { get; init; }
|
||||
public string Feature { get; init; }
|
||||
public PatronTier RequiredTier { get; init; }
|
||||
public PatronTier UserTier { get; init; }
|
||||
}
|
150
src/NadekoBot/Modules/Utility/Patronage/Patreon/PatreonClient.cs
Normal file
150
src/NadekoBot/Modules/Utility/Patronage/Patreon/PatreonClient.cs
Normal file
@@ -0,0 +1,150 @@
|
||||
#nullable disable
|
||||
using NadekoBot.Modules.Utility.Common.Patreon;
|
||||
using OneOf;
|
||||
using OneOf.Types;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace NadekoBot.Modules.Utility;
|
||||
|
||||
public class PatreonClient : IDisposable
|
||||
{
|
||||
private readonly string _clientId;
|
||||
private readonly string _clientSecret;
|
||||
private string refreshToken;
|
||||
|
||||
|
||||
private string accessToken = string.Empty;
|
||||
private readonly HttpClient _http;
|
||||
|
||||
private DateTime refreshAt = DateTime.UtcNow;
|
||||
|
||||
public PatreonClient(string clientId, string clientSecret, string refreshToken)
|
||||
{
|
||||
_clientId = clientId;
|
||||
_clientSecret = clientSecret;
|
||||
this.refreshToken = refreshToken;
|
||||
|
||||
_http = new();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
=> _http.Dispose();
|
||||
|
||||
public PatreonCredentials GetCredentials()
|
||||
=> new PatreonCredentials()
|
||||
{
|
||||
AccessToken = accessToken,
|
||||
ClientId = _clientId,
|
||||
ClientSecret = _clientSecret,
|
||||
RefreshToken = refreshToken,
|
||||
};
|
||||
|
||||
public async Task<OneOf<Success, Error<string>>> RefreshTokenAsync(bool force)
|
||||
{
|
||||
if (!force && IsTokenValid())
|
||||
return new Success();
|
||||
|
||||
var res = await _http.PostAsync("https://www.patreon.com/api/oauth2/token"
|
||||
+ "?grant_type=refresh_token"
|
||||
+ $"&refresh_token={refreshToken}"
|
||||
+ $"&client_id={_clientId}"
|
||||
+ $"&client_secret={_clientSecret}",
|
||||
null);
|
||||
|
||||
if (!res.IsSuccessStatusCode)
|
||||
return new Error<string>($"Request did not return a sucess status code. Status code: {res.StatusCode}");
|
||||
|
||||
try
|
||||
{
|
||||
var data = await res.Content.ReadFromJsonAsync<PatreonRefreshData>();
|
||||
|
||||
if (data is null)
|
||||
return new Error<string>($"Invalid data retrieved from Patreon.");
|
||||
|
||||
refreshToken = data.RefreshToken;
|
||||
accessToken = data.AccessToken;
|
||||
|
||||
refreshAt = DateTime.UtcNow.AddSeconds(data.ExpiresIn - 5.Minutes().TotalSeconds);
|
||||
return new Success();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new Error<string>($"Error during deserialization: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask<bool> EnsureTokenValidAsync()
|
||||
{
|
||||
if (!IsTokenValid())
|
||||
{
|
||||
var res = await RefreshTokenAsync(true);
|
||||
return res.Match(
|
||||
static _ => true,
|
||||
static err =>
|
||||
{
|
||||
Log.Warning("Error getting token: {ErrorMessage}", err.Value);
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool IsTokenValid()
|
||||
=> refreshAt > DateTime.UtcNow && !string.IsNullOrWhiteSpace(accessToken);
|
||||
|
||||
public async Task<OneOf<IAsyncEnumerable<IReadOnlyCollection<PatreonMemberData>>, Error<string>>> GetMembersAsync(string campaignId)
|
||||
{
|
||||
if (!await EnsureTokenValidAsync())
|
||||
return new Error<string>("Unable to get patreon token");
|
||||
|
||||
return OneOf<IAsyncEnumerable<IReadOnlyCollection<PatreonMemberData>>, Error<string>>.FromT0(
|
||||
GetMembersInternalAsync(campaignId));
|
||||
}
|
||||
|
||||
private async IAsyncEnumerable<IReadOnlyCollection<PatreonMemberData>> GetMembersInternalAsync(string campaignId)
|
||||
{
|
||||
_http.DefaultRequestHeaders.Clear();
|
||||
_http.DefaultRequestHeaders.TryAddWithoutValidation("Authorization",
|
||||
$"Bearer {accessToken}");
|
||||
|
||||
var page =
|
||||
$"https://www.patreon.com/api/oauth2/v2/campaigns/{campaignId}/members"
|
||||
+ $"?fields%5Bmember%5D=full_name,currently_entitled_amount_cents,last_charge_date,last_charge_status"
|
||||
+ $"&fields%5Buser%5D=social_connections"
|
||||
+ $"&include=user"
|
||||
+ $"&sort=-last_charge_date";
|
||||
PatreonMembersResponse data;
|
||||
|
||||
do
|
||||
{
|
||||
var res = await _http.GetStreamAsync(page);
|
||||
data = await JsonSerializer.DeserializeAsync<PatreonMembersResponse>(res);
|
||||
|
||||
if (data is null)
|
||||
break;
|
||||
|
||||
var userData = data.Data
|
||||
.Join(data.Included,
|
||||
static m => m.Relationships.User.Data.Id,
|
||||
static u => u.Id,
|
||||
static (m, u) => new PatreonMemberData()
|
||||
{
|
||||
PatreonUserId = m.Relationships.User.Data.Id,
|
||||
UserId = ulong.TryParse(
|
||||
u.Attributes?.SocialConnections?.Discord?.UserId ?? string.Empty,
|
||||
out var userId)
|
||||
? userId
|
||||
: 0,
|
||||
EntitledToCents = m.Attributes.CurrentlyEntitledAmountCents,
|
||||
LastChargeDate = m.Attributes.LastChargeDate,
|
||||
LastChargeStatus = m.Attributes.LastChargeStatus
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
yield return userData;
|
||||
|
||||
} while (!string.IsNullOrWhiteSpace(page = data.Links?.Next));
|
||||
}
|
||||
}
|
@@ -0,0 +1,10 @@
|
||||
#nullable disable
|
||||
namespace NadekoBot.Modules.Utility;
|
||||
|
||||
public readonly struct PatreonCredentials
|
||||
{
|
||||
public string ClientId { get; init; }
|
||||
public string ClientSecret { get; init; }
|
||||
public string AccessToken { get; init; }
|
||||
public string RefreshToken { get; init; }
|
||||
}
|
@@ -0,0 +1,33 @@
|
||||
#nullable disable
|
||||
namespace NadekoBot.Modules.Utility;
|
||||
|
||||
public sealed class PatreonMemberData : ISubscriberData
|
||||
{
|
||||
public string PatreonUserId { get; init; }
|
||||
public ulong UserId { get; init; }
|
||||
public DateTime? LastChargeDate { get; init; }
|
||||
public string LastChargeStatus { get; init; }
|
||||
public int EntitledToCents { get; init; }
|
||||
|
||||
public string UniquePlatformUserId
|
||||
=> PatreonUserId;
|
||||
ulong ISubscriberData.UserId
|
||||
=> UserId;
|
||||
public int Cents
|
||||
=> EntitledToCents;
|
||||
public DateTime? LastCharge
|
||||
=> LastChargeDate;
|
||||
public SubscriptionChargeStatus ChargeStatus
|
||||
=> LastChargeStatus switch
|
||||
{
|
||||
"Paid" => SubscriptionChargeStatus.Paid,
|
||||
"Fraud" or "Refunded" => SubscriptionChargeStatus.Refunded,
|
||||
"Declined" or "Pending" => SubscriptionChargeStatus.Unpaid,
|
||||
_ => SubscriptionChargeStatus.Other,
|
||||
};
|
||||
}
|
||||
|
||||
public sealed class PatreonPledgeData
|
||||
{
|
||||
|
||||
}
|
@@ -0,0 +1,81 @@
|
||||
#nullable disable
|
||||
using NadekoBot.Modules.Utility.Patronage;
|
||||
|
||||
namespace NadekoBot.Modules.Utility;
|
||||
|
||||
/// <summary>
|
||||
/// Service tasked with handling pledges on patreon
|
||||
/// </summary>
|
||||
public sealed class PatreonSubscriptionHandler : ISubscriptionHandler, INService
|
||||
{
|
||||
private readonly IBotCredsProvider _credsProvider;
|
||||
private readonly PatreonClient _patreonClient;
|
||||
|
||||
public PatreonSubscriptionHandler(IBotCredsProvider credsProvider)
|
||||
{
|
||||
_credsProvider = credsProvider;
|
||||
var botCreds = credsProvider.GetCreds();
|
||||
_patreonClient = new PatreonClient(botCreds.Patreon.ClientId, botCreds.Patreon.ClientSecret, botCreds.Patreon.RefreshToken);
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<IReadOnlyCollection<ISubscriberData>> GetPatronsAsync()
|
||||
{
|
||||
var botCreds = _credsProvider.GetCreds();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(botCreds.Patreon.CampaignId)
|
||||
|| string.IsNullOrWhiteSpace(botCreds.Patreon.ClientId)
|
||||
|| string.IsNullOrWhiteSpace(botCreds.Patreon.ClientSecret)
|
||||
|| string.IsNullOrWhiteSpace(botCreds.Patreon.RefreshToken))
|
||||
yield break;
|
||||
|
||||
var result = await _patreonClient.RefreshTokenAsync(false);
|
||||
if (!result.TryPickT0(out _, out var error))
|
||||
{
|
||||
Log.Warning("Unable to refresh patreon token: {ErrorMessage}", error.Value);
|
||||
yield break;
|
||||
}
|
||||
|
||||
var patreonCreds = _patreonClient.GetCredentials();
|
||||
|
||||
_credsProvider.ModifyCredsFile(c =>
|
||||
{
|
||||
c.Patreon.AccessToken = patreonCreds.AccessToken;
|
||||
c.Patreon.RefreshToken = patreonCreds.RefreshToken;
|
||||
});
|
||||
|
||||
IAsyncEnumerable<IEnumerable<ISubscriberData>> data;
|
||||
try
|
||||
{
|
||||
var maybeUserData = await _patreonClient.GetMembersAsync(botCreds.Patreon.CampaignId);
|
||||
data = maybeUserData.Match(
|
||||
static userData => userData,
|
||||
static err =>
|
||||
{
|
||||
Log.Warning("Error while getting patreon members: {ErrorMessage}", err.Value);
|
||||
return AsyncEnumerable.Empty<IReadOnlyCollection<ISubscriberData>>();
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex,
|
||||
"Unexpected error while refreshing patreon members: {ErroMessage}",
|
||||
ex.Message);
|
||||
|
||||
yield break;
|
||||
}
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var firstOfThisMonth = new DateOnly(now.Year, now.Month, 1);
|
||||
await foreach (var batch in data)
|
||||
{
|
||||
// send only active patrons
|
||||
var toReturn = batch.Where(x => x.Cents > 0
|
||||
&& x.LastCharge is { } lc
|
||||
&& lc.ToUniversalTime().ToDateOnly() >= firstOfThisMonth)
|
||||
.ToArray();
|
||||
|
||||
if (toReturn.Length > 0)
|
||||
yield return toReturn;
|
||||
}
|
||||
}
|
||||
}
|
38
src/NadekoBot/Modules/Utility/Patronage/Patron.cs
Normal file
38
src/NadekoBot/Modules/Utility/Patronage/Patron.cs
Normal file
@@ -0,0 +1,38 @@
|
||||
namespace NadekoBot.Modules.Utility.Patronage;
|
||||
|
||||
public readonly struct Patron
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique id assigned to this patron by the payment platform
|
||||
/// </summary>
|
||||
public string UnqiuePlatformUserId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Discord UserId to which this <see cref="UnqiuePlatformUserId"/> is connected to
|
||||
/// </summary>
|
||||
public ulong UserId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Amount the Patron is currently pledging or paid
|
||||
/// </summary>
|
||||
public int Amount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current Tier of the patron
|
||||
/// (do not question it in consumer classes, as the calculation should be always internal and may change)
|
||||
/// </summary>
|
||||
public PatronTier Tier { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When was the last time this <see cref="Amount"/> was paid
|
||||
/// </summary>
|
||||
public DateTime PaidAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// After which date does the user's Patronage benefit end
|
||||
/// </summary>
|
||||
public DateTime ValidThru { get; init; }
|
||||
|
||||
public bool IsActive
|
||||
=> !ValidThru.IsBeforeToday();
|
||||
}
|
39
src/NadekoBot/Modules/Utility/Patronage/PatronExtensions.cs
Normal file
39
src/NadekoBot/Modules/Utility/Patronage/PatronExtensions.cs
Normal file
@@ -0,0 +1,39 @@
|
||||
namespace NadekoBot.Modules.Utility.Patronage;
|
||||
|
||||
public static class PatronExtensions
|
||||
{
|
||||
public static DateOnly ToDateOnly(this DateTime dateTime)
|
||||
=> DateOnly.FromDateTime(dateTime);
|
||||
|
||||
public static bool IsBeforeToday(this DateTime date)
|
||||
=> date < DateTime.UtcNow.Date;
|
||||
|
||||
public static string ToFullName(this PatronTier tier)
|
||||
=> tier switch
|
||||
{
|
||||
_ => $"Patron Tier {tier}",
|
||||
};
|
||||
|
||||
public static string ToFullName(this QuotaPer per)
|
||||
=> per.Humanize(LetterCasing.LowerCase);
|
||||
|
||||
public static DateTime DayOfNextMonth(this DateTime date, int day)
|
||||
{
|
||||
var nextMonth = date.AddMonths(1);
|
||||
var dt = DateTime.SpecifyKind(new(nextMonth.Year, nextMonth.Month, day), DateTimeKind.Utc);
|
||||
return dt;
|
||||
}
|
||||
|
||||
public static DateTime FirstOfNextMonth(this DateTime date)
|
||||
=> date.DayOfNextMonth(1);
|
||||
|
||||
public static DateTime SecondOfNextMonth(this DateTime date)
|
||||
=> date.DayOfNextMonth(2);
|
||||
|
||||
public static string ToShortAndRelativeTimestampTag(this DateTime date)
|
||||
{
|
||||
var fullResetStr = TimestampTag.FromDateTime(date, TimestampTagStyles.ShortDateTime);
|
||||
var relativeResetStr = TimestampTag.FromDateTime(date, TimestampTagStyles.Relative);
|
||||
return $"{fullResetStr}\n{relativeResetStr}";
|
||||
}
|
||||
}
|
153
src/NadekoBot/Modules/Utility/Patronage/PatronageCommands.cs
Normal file
153
src/NadekoBot/Modules/Utility/Patronage/PatronageCommands.cs
Normal file
@@ -0,0 +1,153 @@
|
||||
using NadekoBot.Modules.Utility.Patronage;
|
||||
|
||||
namespace NadekoBot.Modules.Utility;
|
||||
|
||||
public partial class Utility
|
||||
{
|
||||
[OnlyPublicBot]
|
||||
public partial class PatronageCommands : NadekoModule
|
||||
{
|
||||
private readonly PatronageService _service;
|
||||
private readonly PatronageConfig _pConf;
|
||||
|
||||
public PatronageCommands(PatronageService service, PatronageConfig pConf)
|
||||
{
|
||||
_service = service;
|
||||
_pConf = pConf;
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[Priority(2)]
|
||||
public partial Task Patron()
|
||||
=> InternalPatron(ctx.User);
|
||||
|
||||
[Cmd]
|
||||
[Priority(0)]
|
||||
[OwnerOnly]
|
||||
public partial Task Patron(IUser user)
|
||||
=> InternalPatron(user);
|
||||
|
||||
[Cmd]
|
||||
[Priority(0)]
|
||||
[OwnerOnly]
|
||||
public async partial Task PatronMessage(PatronTier tierAndHigher, string message)
|
||||
{
|
||||
_ = ctx.Channel.TriggerTypingAsync();
|
||||
var result = await _service.SendMessageToPatronsAsync(tierAndHigher, message);
|
||||
|
||||
await ReplyConfirmLocalizedAsync(strs.patron_msg_sent(
|
||||
Format.Code(tierAndHigher.ToString()),
|
||||
Format.Bold(result.Success.ToString()),
|
||||
Format.Bold(result.Failed.ToString())));
|
||||
}
|
||||
|
||||
// [Cmd]
|
||||
// [OwnerOnly]
|
||||
// public async partial Task PatronGift(IUser user, int amount)
|
||||
// {
|
||||
// // i can't figure out a good way to gift more than one month at the moment.
|
||||
//
|
||||
// if (amount < 1)
|
||||
// return;
|
||||
//
|
||||
// var patron = _service.GiftPatronAsync(user, amount);
|
||||
//
|
||||
// var eb = _eb.Create(ctx);
|
||||
//
|
||||
// await ctx.Channel.EmbedAsync(eb.WithDescription($"Added **{days}** days of Patron benefits to {user.Mention}!")
|
||||
// .AddField("Tier", Format.Bold(patron.Tier.ToString()), true)
|
||||
// .AddField("Amount", $"**{patron.Amount / 100.0f:N1}$**", true)
|
||||
// .AddField("Until", TimestampTag.FromDateTime(patron.ValidThru.AddDays(1))));
|
||||
//
|
||||
//
|
||||
// }
|
||||
|
||||
private async Task InternalPatron(IUser user)
|
||||
{
|
||||
if (!_pConf.Data.IsEnabled)
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.patron_not_enabled);
|
||||
return;
|
||||
}
|
||||
|
||||
var patron = await _service.GetPatronAsync(user.Id);
|
||||
var quotaStats = await _service.GetUserQuotaStatistic(user.Id);
|
||||
|
||||
var eb = _eb.Create(ctx)
|
||||
.WithAuthor(user)
|
||||
.WithTitle(GetText(strs.patron_info))
|
||||
.WithOkColor();
|
||||
|
||||
if (quotaStats.Commands.Count == 0
|
||||
&& quotaStats.Groups.Count == 0
|
||||
&& quotaStats.Modules.Count == 0)
|
||||
{
|
||||
eb.WithDescription(GetText(strs.no_quota_found));
|
||||
}
|
||||
else
|
||||
{
|
||||
eb.AddField(GetText(strs.tier), Format.Bold(patron.Tier.ToFullName()), true)
|
||||
.AddField(GetText(strs.pledge), $"**{patron.Amount / 100.0f:N1}$**", true);
|
||||
|
||||
if (patron.Tier != PatronTier.None)
|
||||
eb.AddField(GetText(strs.expires), patron.ValidThru.AddDays(1).ToShortAndRelativeTimestampTag(), true);
|
||||
|
||||
eb.AddField(GetText(strs.quotas), "", false);
|
||||
|
||||
if (quotaStats.Commands.Count > 0)
|
||||
{
|
||||
var text = GetQuotaList(quotaStats.Commands);
|
||||
if (!string.IsNullOrWhiteSpace(text))
|
||||
eb.AddField(GetText(strs.commands), text, true);
|
||||
}
|
||||
|
||||
if (quotaStats.Groups.Count > 0)
|
||||
{
|
||||
var text = GetQuotaList(quotaStats.Groups);
|
||||
if (!string.IsNullOrWhiteSpace(text))
|
||||
eb.AddField(GetText(strs.groups), text, true);
|
||||
}
|
||||
|
||||
if (quotaStats.Modules.Count > 0)
|
||||
{
|
||||
var text = GetQuotaList(quotaStats.Modules);
|
||||
if (!string.IsNullOrWhiteSpace(text))
|
||||
eb.AddField(GetText(strs.modules), text, true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
try
|
||||
{
|
||||
await ctx.User.EmbedAsync(eb);
|
||||
_ = ctx.OkAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.cant_dm);
|
||||
}
|
||||
}
|
||||
|
||||
private string GetQuotaList(IReadOnlyDictionary<string, FeatureQuotaStats> featureQuotaStats)
|
||||
{
|
||||
var text = string.Empty;
|
||||
foreach (var (key, q) in featureQuotaStats)
|
||||
{
|
||||
text += $"\n\t`{key}`\n";
|
||||
if (q.Hourly != default)
|
||||
text += $" {GetEmoji(q.Hourly)} {q.Hourly.Cur}/{q.Hourly.Max} per hour\n";
|
||||
if (q.Daily != default)
|
||||
text += $" {GetEmoji(q.Daily)} {q.Daily.Cur}/{q.Daily.Max} per day\n";
|
||||
if (q.Monthly != default)
|
||||
text += $" {GetEmoji(q.Monthly)} {q.Monthly.Cur}/{q.Monthly.Max} per month\n";
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
private string GetEmoji((uint Cur, uint Max) limit)
|
||||
=> limit.Cur < limit.Max
|
||||
? "✅"
|
||||
: "⚠️";
|
||||
}
|
||||
}
|
834
src/NadekoBot/Modules/Utility/Patronage/PatronageService.cs
Normal file
834
src/NadekoBot/Modules/Utility/Patronage/PatronageService.cs
Normal file
@@ -0,0 +1,834 @@
|
||||
using LinqToDB;
|
||||
using LinqToDB.EntityFrameworkCore;
|
||||
using NadekoBot.Common.ModuleBehaviors;
|
||||
using NadekoBot.Db.Models;
|
||||
using OneOf;
|
||||
using StackExchange.Redis;
|
||||
using CommandInfo = Discord.Commands.CommandInfo;
|
||||
|
||||
namespace NadekoBot.Modules.Utility.Patronage;
|
||||
|
||||
/// <inheritdoc cref="IPatronageService"/>
|
||||
public sealed class PatronageService
|
||||
: IPatronageService,
|
||||
IReadyExecutor,
|
||||
IExecPreCommand,
|
||||
INService
|
||||
{
|
||||
public event Func<Patron, Task> OnNewPatronPayment = static delegate { return Task.CompletedTask; };
|
||||
public event Func<Patron, Patron, Task> OnPatronUpdated = static delegate { return Task.CompletedTask; };
|
||||
public event Func<Patron, Task> OnPatronRefunded = static delegate { return Task.CompletedTask; };
|
||||
|
||||
// this has to run right before the command
|
||||
public int Priority
|
||||
=> int.MinValue;
|
||||
|
||||
private static readonly PatronTier[] _tiers = Enum.GetValues<PatronTier>();
|
||||
|
||||
private readonly PatronageConfig _pConf;
|
||||
private readonly DbService _db;
|
||||
private readonly DiscordSocketClient _client;
|
||||
private readonly ISubscriptionHandler _subsHandler;
|
||||
private readonly IEmbedBuilderService _eb;
|
||||
private readonly ConnectionMultiplexer _redis;
|
||||
private readonly IBotCredentials _creds;
|
||||
private readonly TypedKey<bool> _quotaKey;
|
||||
|
||||
public PatronageService(
|
||||
PatronageConfig pConf,
|
||||
DbService db,
|
||||
DiscordSocketClient client,
|
||||
ISubscriptionHandler subsHandler,
|
||||
IEmbedBuilderService eb,
|
||||
ConnectionMultiplexer redis,
|
||||
IBotCredentials creds)
|
||||
{
|
||||
_pConf = pConf;
|
||||
_db = db;
|
||||
_client = client;
|
||||
_subsHandler = subsHandler;
|
||||
_eb = eb;
|
||||
_redis = redis;
|
||||
_creds = creds;
|
||||
|
||||
_quotaKey = new TypedKey<bool>($"{_creds.RedisKey()}:quota:last_hourly_reset");
|
||||
}
|
||||
|
||||
public Task OnReadyAsync()
|
||||
{
|
||||
if (_client.ShardId != 0)
|
||||
return Task.CompletedTask;
|
||||
|
||||
return Task.WhenAll(ResetLoopAsync(), LoadSubscribersLoopAsync());
|
||||
}
|
||||
|
||||
private async Task LoadSubscribersLoopAsync()
|
||||
{
|
||||
var timer = new PeriodicTimer(TimeSpan.FromSeconds(60));
|
||||
while (await timer.WaitForNextTickAsync())
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_pConf.Data.IsEnabled)
|
||||
continue;
|
||||
|
||||
await foreach (var batch in _subsHandler.GetPatronsAsync())
|
||||
{
|
||||
await ProcesssPatronsAsync(batch);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Error processing patrons");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task ResetLoopAsync()
|
||||
{
|
||||
await Task.Delay(1.Minutes());
|
||||
while (true)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_pConf.Data.IsEnabled)
|
||||
{
|
||||
await Task.Delay(1.Minutes());
|
||||
continue;
|
||||
}
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var lastRun = DateTime.MinValue;
|
||||
|
||||
var rdb = _redis.GetDatabase();
|
||||
var lastVal = await rdb.StringGetAsync(_quotaKey.Key);
|
||||
if (lastVal != default)
|
||||
{
|
||||
lastRun = DateTime.FromBinary((long)lastVal);
|
||||
}
|
||||
|
||||
var nowDate = now.ToDateOnly();
|
||||
var lastDate = lastRun.ToDateOnly();
|
||||
|
||||
await using var ctx = _db.GetDbContext();
|
||||
await using var tran = await ctx.Database.BeginTransactionAsync();
|
||||
|
||||
if ((lastDate.Day == 1 || (lastDate.Month != nowDate.Month)) && nowDate.Day > 1)
|
||||
{
|
||||
// assumes bot won't be offline for a year
|
||||
await ctx.GetTable<PatronQuota>()
|
||||
.TruncateAsync();
|
||||
}
|
||||
else if (nowDate.DayNumber != lastDate.DayNumber)
|
||||
{
|
||||
// day is different, means hour is different.
|
||||
// reset both hourly and daily quota counts.
|
||||
await ctx.GetTable<PatronQuota>()
|
||||
.UpdateAsync((old) => new()
|
||||
{
|
||||
HourlyCount = 0,
|
||||
DailyCount = 0,
|
||||
});
|
||||
|
||||
await rdb.StringSetAsync(_quotaKey.Key, true);
|
||||
}
|
||||
else if (now.Hour != lastRun.Hour) // if it's not, just reset hourly quotas
|
||||
{
|
||||
await ctx.GetTable<PatronQuota>()
|
||||
.UpdateAsync((old) => new()
|
||||
{
|
||||
HourlyCount = 0
|
||||
});
|
||||
}
|
||||
|
||||
// assumes that the code above runs in less than an hour
|
||||
await rdb.StringSetAsync(_quotaKey.Key, now.ToBinary());
|
||||
await tran.CommitAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Error in quota reset loop. Message: {ErrorMessage}", ex.Message);
|
||||
}
|
||||
|
||||
await Task.Delay(TimeSpan.FromHours(1).Add(TimeSpan.FromMinutes(1)));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ProcesssPatronsAsync(IReadOnlyCollection<ISubscriberData> subscribersEnum)
|
||||
{
|
||||
// process only users who have discord accounts connected
|
||||
var subscribers = subscribersEnum.Where(x => x.UserId != 0).ToArray();
|
||||
|
||||
if (subscribers.Length == 0)
|
||||
return;
|
||||
|
||||
var todayDate = DateTime.UtcNow.Date;
|
||||
await using var ctx = _db.GetDbContext();
|
||||
|
||||
// handle paid users
|
||||
foreach (var subscriber in subscribers.Where(x => x.ChargeStatus == SubscriptionChargeStatus.Paid))
|
||||
{
|
||||
if (subscriber.LastCharge is null)
|
||||
continue;
|
||||
|
||||
var lastChargeUtc = subscriber.LastCharge.Value.ToUniversalTime();
|
||||
var dateInOneMonth = lastChargeUtc.Date.AddMonths(1);
|
||||
await using var tran = await ctx.Database.BeginTransactionAsync();
|
||||
try
|
||||
{
|
||||
var dbPatron = await ctx.GetTable<PatronUser>()
|
||||
.FirstOrDefaultAsync(x
|
||||
=> x.UniquePlatformUserId == subscriber.UniquePlatformUserId);
|
||||
|
||||
if (dbPatron is null)
|
||||
{
|
||||
// if the user is not in the database alrady
|
||||
dbPatron = await ctx.GetTable<PatronUser>()
|
||||
.InsertWithOutputAsync(() => new()
|
||||
{
|
||||
UniquePlatformUserId = subscriber.UniquePlatformUserId,
|
||||
UserId = subscriber.UserId,
|
||||
AmountCents = subscriber.Cents,
|
||||
LastCharge = lastChargeUtc,
|
||||
ValidThru = dateInOneMonth,
|
||||
});
|
||||
|
||||
await tran.CommitAsync();
|
||||
|
||||
var newPatron = PatronUserToPatron(dbPatron);
|
||||
_ = SendWelcomeMessage(newPatron);
|
||||
await OnNewPatronPayment(newPatron);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (dbPatron.LastCharge.Month < lastChargeUtc.Month)
|
||||
{
|
||||
// user is charged again for this month
|
||||
// if his sub would end in teh future, extend it by one month.
|
||||
// if it's not, just add 1 month to the last charge date
|
||||
var count = await ctx.GetTable<PatronUser>()
|
||||
.Where(x => x.UniquePlatformUserId == subscriber.UniquePlatformUserId)
|
||||
.UpdateAsync(old => new()
|
||||
{
|
||||
UserId = subscriber.UserId,
|
||||
AmountCents = subscriber.Cents,
|
||||
LastCharge = lastChargeUtc,
|
||||
ValidThru = old.ValidThru >= todayDate
|
||||
// ? Sql.DateAdd(Sql.DateParts.Month, 1, old.ValidThru).Value
|
||||
? old.ValidThru.AddMonths(1)
|
||||
: dateInOneMonth,
|
||||
});
|
||||
|
||||
// this should never happen
|
||||
if (count == 0)
|
||||
{
|
||||
await tran.RollbackAsync();
|
||||
continue;
|
||||
}
|
||||
|
||||
await tran.CommitAsync();
|
||||
|
||||
await OnNewPatronPayment(PatronUserToPatron(dbPatron));
|
||||
}
|
||||
else if (dbPatron.AmountCents != subscriber.Cents // if user changed the amount
|
||||
|| dbPatron.UserId != subscriber.UserId) // if user updated user id)
|
||||
{
|
||||
// the user updated the pledge or changed the connected discord account
|
||||
var newData = await ctx.GetTable<PatronUser>()
|
||||
.Where(x => x.UniquePlatformUserId == subscriber.UniquePlatformUserId
|
||||
&& x.LastCharge < lastChargeUtc)
|
||||
.UpdateWithOutputAsync(old => new()
|
||||
{
|
||||
UserId = subscriber.UserId,
|
||||
AmountCents = subscriber.Cents,
|
||||
LastCharge = lastChargeUtc,
|
||||
ValidThru = old.ValidThru,
|
||||
});
|
||||
await tran.CommitAsync();
|
||||
|
||||
// this should never happen
|
||||
if (newData.Length == 0)
|
||||
continue;
|
||||
|
||||
await OnPatronUpdated(PatronUserToPatron(dbPatron), PatronUserToPatron(newData[0].Inserted));
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex,
|
||||
"Unexpected error occured while processing rewards for patron {UserId}",
|
||||
subscriber.UserId);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var patron in subscribers.Where(x => x.ChargeStatus == SubscriptionChargeStatus.Refunded))
|
||||
{
|
||||
var expiredDate = DateTime.MinValue;
|
||||
// if the subscription is refunded, Disable user's valid thru
|
||||
var output = await ctx.GetTable<PatronUser>()
|
||||
.Where(x => x.UniquePlatformUserId == patron.UniquePlatformUserId
|
||||
&& x.ValidThru != expiredDate)
|
||||
.UpdateWithOutputAsync(old => new()
|
||||
{
|
||||
ValidThru = expiredDate
|
||||
});
|
||||
|
||||
if (output.Length == 0)
|
||||
continue;
|
||||
|
||||
await OnPatronRefunded(PatronUserToPatron(output[0].Inserted));
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> ExecPreCommandAsync(
|
||||
ICommandContext ctx,
|
||||
string moduleName,
|
||||
CommandInfo command)
|
||||
{
|
||||
var ownerId = ctx.Guild?.OwnerId ?? 0;
|
||||
|
||||
var result = await AttemptRunCommand(
|
||||
ctx.User.Id,
|
||||
ownerId: ownerId,
|
||||
command.Aliases.First().ToLowerInvariant(),
|
||||
command.Module.Parent == null ? string.Empty : command.Module.GetGroupName().ToLowerInvariant(),
|
||||
moduleName.ToLowerInvariant()
|
||||
);
|
||||
|
||||
return result.Match(
|
||||
_ => false,
|
||||
ins =>
|
||||
{
|
||||
var eb = _eb.Create(ctx)
|
||||
.WithPendingColor()
|
||||
.WithTitle("Insufficient Patron Tier")
|
||||
.AddField("For", $"{ins.FeatureType}: `{ins.Feature}`", true)
|
||||
.AddField("Required Tier",
|
||||
$"[{ins.RequiredTier.ToFullName()}](https://patreon.com/join/nadekobot)",
|
||||
true);
|
||||
|
||||
if (ctx.Guild is null || ctx.Guild?.OwnerId == ctx.User.Id)
|
||||
eb.WithDescription("You don't have the sufficent Patron Tier to run this command.")
|
||||
.WithFooter("You can use '.patron' and '.donate' commands for more info");
|
||||
else
|
||||
eb.WithDescription(
|
||||
"Neither you nor the server owner have the sufficent Patron Tier to run this command.")
|
||||
.WithFooter("You can use '.patron' and '.donate' commands for more info");
|
||||
|
||||
_ = ctx.WarningAsync();
|
||||
|
||||
if (ctx.Guild?.OwnerId == ctx.User.Id)
|
||||
_ = ctx.Channel.EmbedAsync(eb);
|
||||
else
|
||||
_ = ctx.User.EmbedAsync(eb);
|
||||
|
||||
return true;
|
||||
},
|
||||
quota =>
|
||||
{
|
||||
var eb = _eb.Create(ctx)
|
||||
.WithPendingColor()
|
||||
.WithTitle("Quota Limit Reached");
|
||||
|
||||
if (quota.IsOwnQuota || ctx.User.Id == ownerId)
|
||||
{
|
||||
eb.WithDescription($"You've reached your quota of `{quota.Quota} {quota.QuotaPeriod.ToFullName()}`")
|
||||
.WithFooter("You may want to check your quota by using the '.patron' command.");
|
||||
}
|
||||
else
|
||||
{
|
||||
eb.WithDescription(
|
||||
$"This server reached the quota of {quota.Quota} `{quota.QuotaPeriod.ToFullName()}`")
|
||||
.WithFooter("You may contact the server owner about this issue.\n"
|
||||
+ "Alternatively, you can become patron yourself by using the '.donate' command.\n"
|
||||
+ "If you're already a patron, it means you've reached your quota.\n"
|
||||
+ "You can use '.patron' command to check your quota status.");
|
||||
}
|
||||
|
||||
eb.AddField("For", $"{quota.FeatureType}: `{quota.Feature}`", true)
|
||||
.AddField("Resets At", quota.ResetsAt.ToShortAndRelativeTimestampTag(), true);
|
||||
|
||||
_ = ctx.WarningAsync();
|
||||
|
||||
// send the message in the server in case it's the owner
|
||||
if (ctx.Guild?.OwnerId == ctx.User.Id)
|
||||
_ = ctx.Channel.EmbedAsync(eb);
|
||||
else
|
||||
_ = ctx.User.EmbedAsync(eb);
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
private async ValueTask<OneOf<OneOf.Types.Success, InsufficientTier, QuotaLimit>> AttemptRunCommand(
|
||||
ulong userId,
|
||||
ulong ownerId,
|
||||
string commandName,
|
||||
string groupName,
|
||||
string moduleName)
|
||||
{
|
||||
// try to run as a user
|
||||
var res = await AttemptRunCommand(userId, commandName, groupName, moduleName, true);
|
||||
|
||||
// if it fails, try to run as an owner
|
||||
// but only if the command is ran in a server
|
||||
// and if the owner is not the user
|
||||
if (!res.IsT0 && ownerId != 0 && ownerId != userId)
|
||||
res = await AttemptRunCommand(ownerId, commandName, groupName, moduleName, false);
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns either the current usage counter if limit wasn't reached, or QuotaLimit if it is.
|
||||
/// </summary>
|
||||
public async ValueTask<OneOf<(uint Hourly, uint Daily, uint Monthly), QuotaLimit>> TryIncrementQuotaCounterAsync(ulong userId,
|
||||
bool isSelf,
|
||||
FeatureType featureType,
|
||||
string featureName,
|
||||
uint? maybeHourly,
|
||||
uint? maybeDaily,
|
||||
uint? maybeMonthly)
|
||||
{
|
||||
await using var ctx = _db.GetDbContext();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
await using var tran = await ctx.Database.BeginTransactionAsync();
|
||||
|
||||
var userQuotaData = await ctx.GetTable<PatronQuota>()
|
||||
.FirstOrDefaultAsyncLinqToDB(x => x.UserId == userId
|
||||
&& x.Feature == featureName)
|
||||
?? new PatronQuota();
|
||||
|
||||
// if hourly exists, if daily exists, etc...
|
||||
if (maybeHourly is uint hourly && userQuotaData.HourlyCount >= hourly)
|
||||
{
|
||||
return new QuotaLimit()
|
||||
{
|
||||
QuotaPeriod = QuotaPer.PerHour,
|
||||
Quota = hourly,
|
||||
// quite a neat trick. https://stackoverflow.com/a/5733560
|
||||
ResetsAt = now.Date.AddHours(now.Hour + 1),
|
||||
Feature = featureName,
|
||||
FeatureType = featureType,
|
||||
IsOwnQuota = isSelf
|
||||
};
|
||||
}
|
||||
|
||||
if (maybeDaily is uint daily
|
||||
&& userQuotaData.DailyCount >= daily)
|
||||
{
|
||||
return new QuotaLimit()
|
||||
{
|
||||
QuotaPeriod = QuotaPer.PerDay,
|
||||
Quota = daily,
|
||||
ResetsAt = now.Date.AddDays(1),
|
||||
Feature = featureName,
|
||||
FeatureType = featureType,
|
||||
IsOwnQuota = isSelf
|
||||
};
|
||||
}
|
||||
|
||||
if (maybeMonthly is uint monthly && userQuotaData.MonthlyCount >= monthly)
|
||||
{
|
||||
return new QuotaLimit()
|
||||
{
|
||||
QuotaPeriod = QuotaPer.PerMonth,
|
||||
Quota = monthly,
|
||||
ResetsAt = now.Date.SecondOfNextMonth(),
|
||||
Feature = featureName,
|
||||
FeatureType = featureType,
|
||||
IsOwnQuota = isSelf
|
||||
};
|
||||
}
|
||||
|
||||
await ctx.GetTable<PatronQuota>()
|
||||
.InsertOrUpdateAsync(() => new()
|
||||
{
|
||||
UserId = userId,
|
||||
FeatureType = featureType,
|
||||
Feature = featureName,
|
||||
DailyCount = 1,
|
||||
MonthlyCount = 1,
|
||||
HourlyCount = 1,
|
||||
},
|
||||
(old) => new()
|
||||
{
|
||||
HourlyCount = old.HourlyCount + 1,
|
||||
DailyCount = old.DailyCount + 1,
|
||||
MonthlyCount = old.MonthlyCount + 1,
|
||||
},
|
||||
() => new()
|
||||
{
|
||||
UserId = userId,
|
||||
FeatureType = featureType,
|
||||
Feature = featureName,
|
||||
});
|
||||
|
||||
await tran.CommitAsync();
|
||||
|
||||
return (userQuotaData.HourlyCount + 1, userQuotaData.DailyCount + 1, userQuotaData.MonthlyCount + 1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to add 1 to user's quota for the command, group and module.
|
||||
/// Input MUST BE lowercase
|
||||
/// </summary>
|
||||
/// <param name="userId">Id of the user who is attempting to run the command</param>
|
||||
/// <param name="commandName">Name of the command the user is trying to run</param>
|
||||
/// <param name="groupName">Name of the command's group</param>
|
||||
/// <param name="moduleName">Name of the command's top level module</param>
|
||||
/// <param name="isSelf">Whether this is check is for the user himself. False if it's someone else's id (owner)</param>
|
||||
/// <returns>Either a succcess (user can run the command) or one of the error values.</returns>
|
||||
private async ValueTask<OneOf<OneOf.Types.Success, InsufficientTier, QuotaLimit>> AttemptRunCommand(
|
||||
ulong userId,
|
||||
string commandName,
|
||||
string groupName,
|
||||
string moduleName,
|
||||
bool isSelf)
|
||||
{
|
||||
var confData = _pConf.Data;
|
||||
|
||||
if (!confData.IsEnabled)
|
||||
return default;
|
||||
|
||||
if (_creds.IsOwner(userId))
|
||||
return default;
|
||||
|
||||
// get user tier
|
||||
var patron = await GetPatronAsync(userId);
|
||||
FeatureType quotaForFeatureType;
|
||||
|
||||
if (confData.Quotas.Commands.TryGetValue(commandName, out var quotaData))
|
||||
{
|
||||
quotaForFeatureType = FeatureType.Command;
|
||||
}
|
||||
else if (confData.Quotas.Groups.TryGetValue(groupName, out quotaData))
|
||||
{
|
||||
quotaForFeatureType = FeatureType.Group;
|
||||
}
|
||||
else if (confData.Quotas.Modules.TryGetValue(moduleName, out quotaData))
|
||||
{
|
||||
quotaForFeatureType = FeatureType.Module;
|
||||
}
|
||||
else
|
||||
{
|
||||
return default;
|
||||
}
|
||||
|
||||
var featureName = quotaForFeatureType switch
|
||||
{
|
||||
FeatureType.Command => commandName,
|
||||
FeatureType.Group => groupName,
|
||||
FeatureType.Module => moduleName,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(quotaForFeatureType))
|
||||
};
|
||||
|
||||
if (!TryGetTierDataOrLower(quotaData, patron.Tier, out var data))
|
||||
{
|
||||
return new InsufficientTier()
|
||||
{
|
||||
Feature = featureName,
|
||||
FeatureType = quotaForFeatureType,
|
||||
RequiredTier = quotaData.Count == 0
|
||||
? PatronTier.ComingSoon
|
||||
: quotaData.Keys.First(),
|
||||
UserTier = patron.Tier,
|
||||
};
|
||||
}
|
||||
|
||||
// no quota limits for this tier
|
||||
if (data is null)
|
||||
return default;
|
||||
|
||||
var quotaCheckResult = await TryIncrementQuotaCounterAsync(userId,
|
||||
isSelf,
|
||||
quotaForFeatureType,
|
||||
featureName,
|
||||
data.TryGetValue(QuotaPer.PerHour, out var hourly) ? hourly : null,
|
||||
data.TryGetValue(QuotaPer.PerDay, out var daily) ? daily : null,
|
||||
data.TryGetValue(QuotaPer.PerMonth, out var monthly) ? monthly : null
|
||||
);
|
||||
|
||||
return quotaCheckResult.Match(_ => default, x => x);
|
||||
}
|
||||
|
||||
private bool TryGetTierDataOrLower<T>(
|
||||
IReadOnlyDictionary<PatronTier, T?> data,
|
||||
PatronTier tier,
|
||||
out T? o)
|
||||
{
|
||||
// check for quotas on this tier
|
||||
if (data.TryGetValue(tier, out o))
|
||||
return true;
|
||||
|
||||
// if there are none, get the quota first tier below this one
|
||||
// which has quotas specified
|
||||
for (var i = _tiers.Length - 1; i >= 0; i--)
|
||||
{
|
||||
var lowerTier = _tiers[i];
|
||||
if (lowerTier < tier && data.TryGetValue(lowerTier, out o))
|
||||
return true;
|
||||
}
|
||||
|
||||
// if there are none, that means the feature is intended
|
||||
// to be patron-only but the quotas haven't been specified yet
|
||||
// so it will be marked as "Coming Soon"
|
||||
o = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
public async Task<Patron> GetPatronAsync(ulong userId)
|
||||
{
|
||||
await using var ctx = _db.GetDbContext();
|
||||
|
||||
// this can potentially return multiple users if the user
|
||||
// is subscribed on multiple platforms
|
||||
// or if there are multiple users on the same platform who connected the same discord account?!
|
||||
var users = await ctx.GetTable<PatronUser>()
|
||||
.Where(x => x.UserId == userId)
|
||||
.ToListAsync();
|
||||
|
||||
// first find all active subscriptions
|
||||
// and return the one with the highest amount
|
||||
var maxActive = users.Where(x => !x.ValidThru.IsBeforeToday()).MaxBy(x => x.AmountCents);
|
||||
if (maxActive is not null)
|
||||
return PatronUserToPatron(maxActive);
|
||||
|
||||
// if there are no active subs, return the one with the highest amount
|
||||
|
||||
var max = users.MaxBy(x => x.AmountCents);
|
||||
if (max is null)
|
||||
return default; // no patron with that name
|
||||
|
||||
return PatronUserToPatron(max);
|
||||
}
|
||||
|
||||
public async Task<UserQuotaStats> GetUserQuotaStatistic(ulong userId)
|
||||
{
|
||||
var pConfData = _pConf.Data;
|
||||
|
||||
if (!pConfData.IsEnabled)
|
||||
return new();
|
||||
|
||||
var patron = await GetPatronAsync(userId);
|
||||
|
||||
await using var ctx = _db.GetDbContext();
|
||||
var allPatronQuotas = await ctx.GetTable<PatronQuota>()
|
||||
.Where(x => x.UserId == userId)
|
||||
.ToListAsync();
|
||||
|
||||
var allQuotasDict = allPatronQuotas
|
||||
.GroupBy(static x => x.FeatureType)
|
||||
.ToDictionary(static x => x.Key, static x => x.ToDictionary(static y => y.Feature));
|
||||
|
||||
allQuotasDict.TryGetValue(FeatureType.Command, out var data);
|
||||
var userCommandQuotaStats = GetFeatureQuotaStats(patron.Tier, data, pConfData.Quotas.Commands);
|
||||
|
||||
allQuotasDict.TryGetValue(FeatureType.Group, out data);
|
||||
var userGroupQuotaStats = GetFeatureQuotaStats(patron.Tier, data, pConfData.Quotas.Groups);
|
||||
|
||||
allQuotasDict.TryGetValue(FeatureType.Module, out data);
|
||||
var userModuleQuotaStats = GetFeatureQuotaStats(patron.Tier, data, pConfData.Quotas.Modules);
|
||||
|
||||
return new UserQuotaStats()
|
||||
{
|
||||
Tier = patron.Tier,
|
||||
Commands = userCommandQuotaStats,
|
||||
Groups = userGroupQuotaStats,
|
||||
Modules = userModuleQuotaStats,
|
||||
};
|
||||
}
|
||||
|
||||
// should i allow users to pay extra for more quota?
|
||||
private IReadOnlyDictionary<string, FeatureQuotaStats> GetFeatureQuotaStats(
|
||||
PatronTier patronTier,
|
||||
IReadOnlyDictionary<string, PatronQuota>? allQuotasDict,
|
||||
Dictionary<string, Dictionary<PatronTier, Dictionary<QuotaPer, uint>?>> commands)
|
||||
{
|
||||
var userCommandQuotaStats = new Dictionary<string, FeatureQuotaStats>();
|
||||
foreach (var (key, quotaData) in commands)
|
||||
{
|
||||
if (TryGetTierDataOrLower(quotaData, patronTier, out var data))
|
||||
{
|
||||
// if data is null that means the quota for the user's tier is unlimited
|
||||
// no point in returning it?
|
||||
|
||||
if (data is null)
|
||||
continue;
|
||||
|
||||
var (daily, hourly, monthly) = default((uint, uint, uint));
|
||||
// try to get users stats for this feature
|
||||
// if it fails just leave them at 0
|
||||
if (allQuotasDict?.TryGetValue(key, out var quota) ?? false)
|
||||
(daily, hourly, monthly) = (quota.DailyCount, quota.HourlyCount, quota.MonthlyCount);
|
||||
|
||||
userCommandQuotaStats[key] = new FeatureQuotaStats()
|
||||
{
|
||||
Hourly = data.TryGetValue(QuotaPer.PerHour, out var hourD)
|
||||
? (hourly, hourD)
|
||||
: default,
|
||||
Daily = data.TryGetValue(QuotaPer.PerDay, out var maxD)
|
||||
? (daily, maxD)
|
||||
: default,
|
||||
Monthly = data.TryGetValue(QuotaPer.PerMonth, out var maxM)
|
||||
? (monthly, maxM)
|
||||
: default,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return userCommandQuotaStats;
|
||||
}
|
||||
|
||||
public async Task<FeatureLimit> TryGetFeatureLimitAsync(FeatureLimitKey key, ulong userId, int? defaultValue)
|
||||
{
|
||||
var conf = _pConf.Data;
|
||||
|
||||
// if patron system is disabled, the quota is just default
|
||||
if (!conf.IsEnabled)
|
||||
return new()
|
||||
{
|
||||
Name = key.PrettyName,
|
||||
Quota = default,
|
||||
IsPatronLimit = false
|
||||
};
|
||||
|
||||
|
||||
if (!conf.Quotas.Features.TryGetValue(key.Key, out var data))
|
||||
return new()
|
||||
{
|
||||
Name = key.PrettyName,
|
||||
Quota = defaultValue,
|
||||
IsPatronLimit = false,
|
||||
};
|
||||
|
||||
var patron = await GetPatronAsync(userId);
|
||||
if (!TryGetTierDataOrLower(data, patron.Tier, out var limit))
|
||||
return new()
|
||||
{
|
||||
Name = key.PrettyName,
|
||||
Quota = defaultValue,
|
||||
IsPatronLimit = false,
|
||||
};
|
||||
|
||||
return new()
|
||||
{
|
||||
Name = key.PrettyName,
|
||||
Quota = limit,
|
||||
IsPatronLimit = true
|
||||
};
|
||||
}
|
||||
|
||||
// public async Task<Patron> GiftPatronAsync(IUser user, int amount)
|
||||
// {
|
||||
// if (amount < 1)
|
||||
// throw new ArgumentOutOfRangeException(nameof(amount));
|
||||
//
|
||||
//
|
||||
// }
|
||||
|
||||
private Patron PatronUserToPatron(PatronUser user)
|
||||
=> new Patron()
|
||||
{
|
||||
UnqiuePlatformUserId = user.UniquePlatformUserId,
|
||||
UserId = user.UserId,
|
||||
Amount = user.AmountCents,
|
||||
Tier = CalculateTier(user),
|
||||
PaidAt = user.LastCharge,
|
||||
ValidThru = user.ValidThru,
|
||||
};
|
||||
|
||||
private PatronTier CalculateTier(PatronUser user)
|
||||
{
|
||||
if (user.ValidThru.IsBeforeToday())
|
||||
return PatronTier.None;
|
||||
|
||||
return user.AmountCents switch
|
||||
{
|
||||
>= 1000 => PatronTier.X,
|
||||
>= 500 => PatronTier.V,
|
||||
>= 100 => PatronTier.I,
|
||||
_ => PatronTier.None
|
||||
};
|
||||
}
|
||||
|
||||
private async Task SendWelcomeMessage(Patron patron)
|
||||
{
|
||||
try
|
||||
{
|
||||
var user = (IUser)_client.GetUser(patron.UserId) ?? await _client.Rest.GetUserAsync(patron.UserId);
|
||||
if (user is null)
|
||||
return;
|
||||
|
||||
var eb = _eb.Create()
|
||||
.WithOkColor()
|
||||
.WithTitle("❤️ Thank you for supporting NadekoBot! ❤️")
|
||||
.WithDescription(
|
||||
"Your donation has been processed and you will receive the rewards shortly.\n"
|
||||
+ "You can visit <https://www.patreon.com/join/nadekobot> to see rewards for your tier. 🎉")
|
||||
.AddField("Tier", Format.Bold(patron.Tier.ToString()), true)
|
||||
.AddField("Pledge", $"**{patron.Amount / 100.0f:N1}$**", true)
|
||||
.AddField("Expires",
|
||||
patron.ValidThru.AddDays(1).ToShortAndRelativeTimestampTag(),
|
||||
true)
|
||||
.AddField("Instructions",
|
||||
@"*- Within the next **1-2 minutes** you will have all of the benefits of the Tier you've subscribed to.*
|
||||
*- You can check your benefits on <https://www.patreon.com/join/nadekobot>*
|
||||
*- You can use the `.patron` command in this chat to check your current quota usage for the Patron-only commands*
|
||||
*- **ALL** of the servers that you **own** will enjoy your Patron benefits.*
|
||||
*- You can use any of the commands available in your tier on any server (assuming you have sufficient permissions to run those commands)*
|
||||
*- Any user in any of your servers can use Patron-only commands, but they will spend **your quota**, which is why it's recommended to use Nadeko's command cooldown system (.h .cmdcd) or permission system to limit the command usage for your server members.*
|
||||
*- Permission guide can be found here if you're not familiar with it: <https://nadekobot.readthedocs.io/en/latest/permissions-system/>*",
|
||||
isInline: false)
|
||||
.WithFooter($"platform id: {patron.UnqiuePlatformUserId}");
|
||||
|
||||
await user.EmbedAsync(eb);
|
||||
}
|
||||
catch
|
||||
{
|
||||
Log.Warning("Unable to send a \"Welcome\" message to the patron {UserId}", patron.UserId);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<(int Success, int Failed)> SendMessageToPatronsAsync(PatronTier tierAndHigher, string message)
|
||||
{
|
||||
await using var ctx = _db.GetDbContext();
|
||||
|
||||
var patrons = await ctx.GetTable<PatronUser>()
|
||||
.Where(x => x.ValidThru > DateTime.UtcNow)
|
||||
.ToArrayAsync();
|
||||
|
||||
var text = SmartText.CreateFrom(message);
|
||||
|
||||
var succ = 0;
|
||||
var fail = 0;
|
||||
foreach (var patron in patrons)
|
||||
{
|
||||
try
|
||||
{
|
||||
var user = await _client.GetUserAsync(patron.UserId);
|
||||
await user.SendAsync(text);
|
||||
++succ;
|
||||
}
|
||||
catch
|
||||
{
|
||||
++fail;
|
||||
}
|
||||
|
||||
await Task.Delay(1000);
|
||||
}
|
||||
|
||||
return (succ, fail);
|
||||
}
|
||||
|
||||
public PatronConfigData GetConfig()
|
||||
=> _pConf.Data;
|
||||
}
|
||||
|
||||
public readonly struct FeatureLimitKey
|
||||
{
|
||||
public string PrettyName { get; init; }
|
||||
public string Key { get; init; }
|
||||
}
|
66
src/NadekoBot/Modules/Utility/Patronage/QuotaLimit.cs
Normal file
66
src/NadekoBot/Modules/Utility/Patronage/QuotaLimit.cs
Normal file
@@ -0,0 +1,66 @@
|
||||
using NadekoBot.Db.Models;
|
||||
|
||||
namespace NadekoBot.Modules.Utility.Patronage;
|
||||
|
||||
/// <summary>
|
||||
/// Represents information about why the user has triggered a quota limit
|
||||
/// </summary>
|
||||
public readonly struct QuotaLimit
|
||||
{
|
||||
/// <summary>
|
||||
/// Amount of usages reached, which is the limit
|
||||
/// </summary>
|
||||
public uint Quota { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Which period is this quota limit for (hourly, daily, monthly, etc...)
|
||||
/// </summary>
|
||||
public QuotaPer QuotaPeriod { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When does this quota limit reset
|
||||
/// </summary>
|
||||
public DateTime ResetsAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Type of the feature this quota limit is for
|
||||
/// </summary>
|
||||
public FeatureType FeatureType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Name of the feature this quota limit is for
|
||||
/// </summary>
|
||||
public string Feature { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether it is the user's own quota (true), or server owners (false)
|
||||
/// </summary>
|
||||
public bool IsOwnQuota { get; init; }
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Respresent information about the feature limit
|
||||
/// </summary>
|
||||
public readonly struct FeatureLimit
|
||||
{
|
||||
|
||||
/// <summary>
|
||||
/// Whether this limit comes from the patronage system
|
||||
/// </summary>
|
||||
public bool IsPatronLimit { get; init; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum limit allowed
|
||||
/// </summary>
|
||||
public int? Quota { get; init; } = null;
|
||||
|
||||
/// <summary>
|
||||
/// Name of the limit
|
||||
/// </summary>
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
public FeatureLimit()
|
||||
{
|
||||
}
|
||||
}
|
@@ -0,0 +1,10 @@
|
||||
#nullable disable
|
||||
namespace NadekoBot.Modules.Utility;
|
||||
|
||||
public enum SubscriptionChargeStatus
|
||||
{
|
||||
Paid,
|
||||
Refunded,
|
||||
Unpaid,
|
||||
Other,
|
||||
}
|
25
src/NadekoBot/Modules/Utility/Patronage/UserQuotaStats.cs
Normal file
25
src/NadekoBot/Modules/Utility/Patronage/UserQuotaStats.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
namespace NadekoBot.Modules.Utility.Patronage;
|
||||
|
||||
public readonly struct UserQuotaStats
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<string, FeatureQuotaStats> _emptyDictionary
|
||||
= new Dictionary<string, FeatureQuotaStats>();
|
||||
public PatronTier Tier { get; init; }
|
||||
= PatronTier.None;
|
||||
|
||||
public IReadOnlyDictionary<string, FeatureQuotaStats> Features { get; init; }
|
||||
= _emptyDictionary;
|
||||
|
||||
public IReadOnlyDictionary<string, FeatureQuotaStats> Commands { get; init; }
|
||||
= _emptyDictionary;
|
||||
|
||||
public IReadOnlyDictionary<string, FeatureQuotaStats> Groups { get; init; }
|
||||
= _emptyDictionary;
|
||||
|
||||
public IReadOnlyDictionary<string, FeatureQuotaStats> Modules { get; init; }
|
||||
= _emptyDictionary;
|
||||
|
||||
public UserQuotaStats()
|
||||
{
|
||||
}
|
||||
}
|
@@ -0,0 +1,11 @@
|
||||
namespace NadekoBot.Modules.Utility;
|
||||
|
||||
public interface ISubscriberData
|
||||
{
|
||||
public string UniquePlatformUserId { get; }
|
||||
public ulong UserId { get; }
|
||||
public int Cents { get; }
|
||||
|
||||
public DateTime? LastCharge { get; }
|
||||
public SubscriptionChargeStatus ChargeStatus { get; }
|
||||
}
|
@@ -0,0 +1,56 @@
|
||||
using NadekoBot.Db.Models;
|
||||
using OneOf;
|
||||
|
||||
namespace NadekoBot.Modules.Utility.Patronage;
|
||||
|
||||
/// <summary>
|
||||
/// Manages patrons and provides access to their data
|
||||
/// </summary>
|
||||
public interface IPatronageService
|
||||
{
|
||||
/// <summary>
|
||||
/// Called when the payment is made.
|
||||
/// Either as a single payment for that patron,
|
||||
/// or as a recurring monthly donation.
|
||||
/// </summary>
|
||||
public event Func<Patron, Task> OnNewPatronPayment;
|
||||
|
||||
/// <summary>
|
||||
/// Called when the patron changes the pledge amount
|
||||
/// (Patron old, Patron new) => Task
|
||||
/// </summary>
|
||||
public event Func<Patron, Patron, Task> OnPatronUpdated;
|
||||
|
||||
/// <summary>
|
||||
/// Called when the patron refunds the purchase or it's marked as fraud
|
||||
/// </summary>
|
||||
public event Func<Patron, Task> OnPatronRefunded;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a Patron with the specified userId
|
||||
/// </summary>
|
||||
/// <param name="userId">UserId for which to get the patron data for.</param>
|
||||
/// <returns>A patron with the specifeid userId</returns>
|
||||
public Task<Patron> GetPatronAsync(ulong userId);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the quota statistic for the user/patron specified by the userId
|
||||
/// </summary>
|
||||
/// <param name="userId">UserId of the user for which to get the quota statistic for</param>
|
||||
/// <returns>Quota stats for the specified user</returns>
|
||||
Task<UserQuotaStats> GetUserQuotaStatistic(ulong userId);
|
||||
|
||||
|
||||
Task<FeatureLimit> TryGetFeatureLimitAsync(FeatureLimitKey key, ulong userId, int? defaultValue);
|
||||
|
||||
ValueTask<OneOf<(uint Hourly, uint Daily, uint Monthly), QuotaLimit>> TryIncrementQuotaCounterAsync(
|
||||
ulong userId,
|
||||
bool isSelf,
|
||||
FeatureType featureType,
|
||||
string featureName,
|
||||
uint? maybeHourly,
|
||||
uint? maybeDaily,
|
||||
uint? maybeMonthly);
|
||||
|
||||
PatronConfigData GetConfig();
|
||||
}
|
@@ -0,0 +1,16 @@
|
||||
#nullable disable
|
||||
namespace NadekoBot.Modules.Utility;
|
||||
|
||||
/// <summary>
|
||||
/// Services implementing this interface are handling pledges/subscriptions/payments coming
|
||||
/// from a payment platform.
|
||||
/// </summary>
|
||||
public interface ISubscriptionHandler
|
||||
{
|
||||
/// <summary>
|
||||
/// Get Current patrons in batches.
|
||||
/// This will only return patrons who have their discord account connected
|
||||
/// </summary>
|
||||
/// <returns>Batched patrons</returns>
|
||||
public IAsyncEnumerable<IReadOnlyCollection<ISubscriberData>> GetPatronsAsync();
|
||||
}
|
@@ -76,15 +76,14 @@ public class RemindService : INService, IReadyExecutor
|
||||
await uow.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// todo move isonshard to a method
|
||||
private async Task<List<Reminder>> GetRemindersBeforeAsync(DateTime now)
|
||||
{
|
||||
await using var uow = _db.GetDbContext();
|
||||
return await uow.Reminders
|
||||
.ToLinqToDBTable()
|
||||
.Where(x => x.ServerId / 4194304 % (ulong)_creds.TotalShards == (ulong)_client.ShardId
|
||||
&& x.When < now)
|
||||
.ToListAsyncLinqToDB();
|
||||
.ToLinqToDBTable()
|
||||
.Where(x => Linq2DbExpressions.GuildOnShard(x.ServerId, _creds.TotalShards, _client.ShardId)
|
||||
&& x.When < now)
|
||||
.ToListAsyncLinqToDB();
|
||||
}
|
||||
|
||||
public bool TryParseRemindMessage(string input, out RemindObject obj)
|
||||
|
@@ -465,9 +465,6 @@ public partial class Utility : NadekoModule
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
#if GLOBAL_NADEKO
|
||||
[Ratelimit(30)]
|
||||
#endif
|
||||
public async partial Task Ping()
|
||||
{
|
||||
await sem.WaitAsync(5000);
|
||||
|
@@ -6,7 +6,7 @@ namespace NadekoBot.Modules.Utility.Services;
|
||||
|
||||
public class VerboseErrorsService : INService
|
||||
{
|
||||
private readonly ConcurrentHashSet<ulong> _guildsEnabled;
|
||||
private readonly ConcurrentHashSet<ulong> _guildsDisabled;
|
||||
private readonly DbService _db;
|
||||
private readonly CommandHandler _ch;
|
||||
private readonly HelpService _hs;
|
||||
@@ -23,12 +23,12 @@ public class VerboseErrorsService : INService
|
||||
|
||||
_ch.CommandErrored += LogVerboseError;
|
||||
|
||||
_guildsEnabled = new(bot.AllGuildConfigs.Where(x => x.VerboseErrors).Select(x => x.GuildId));
|
||||
_guildsDisabled = new(bot.AllGuildConfigs.Where(x => !x.VerboseErrors).Select(x => x.GuildId));
|
||||
}
|
||||
|
||||
private async Task LogVerboseError(CommandInfo cmd, ITextChannel channel, string reason)
|
||||
{
|
||||
if (channel is null || !_guildsEnabled.Contains(channel.GuildId))
|
||||
if (channel is null || _guildsDisabled.Contains(channel.GuildId))
|
||||
return;
|
||||
|
||||
try
|
||||
@@ -36,35 +36,35 @@ public class VerboseErrorsService : INService
|
||||
var embed = _hs.GetCommandHelp(cmd, channel.Guild)
|
||||
.WithTitle("Command Error")
|
||||
.WithDescription(reason)
|
||||
.WithFooter("Admin may disable verbose errors via `.ve` command")
|
||||
.WithErrorColor();
|
||||
|
||||
await channel.EmbedAsync(embed);
|
||||
}
|
||||
catch
|
||||
{
|
||||
//ignore
|
||||
Log.Information("Verbose error wasn't able to be sent to the server: {GuildId}",
|
||||
channel.GuildId);
|
||||
}
|
||||
}
|
||||
|
||||
public bool ToggleVerboseErrors(ulong guildId, bool? enabled = null)
|
||||
public bool ToggleVerboseErrors(ulong guildId, bool? maybeEnabled = null)
|
||||
{
|
||||
using (var uow = _db.GetDbContext())
|
||||
{
|
||||
var gc = uow.GuildConfigsForId(guildId, set => set);
|
||||
using var uow = _db.GetDbContext();
|
||||
var gc = uow.GuildConfigsForId(guildId, set => set);
|
||||
|
||||
if (enabled == null)
|
||||
enabled = gc.VerboseErrors = !gc.VerboseErrors; // Old behaviour, now behind a condition
|
||||
else
|
||||
gc.VerboseErrors = (bool)enabled; // New behaviour, just set it.
|
||||
if (maybeEnabled is bool isEnabled) // set it
|
||||
gc.VerboseErrors = isEnabled;
|
||||
else // toggle it
|
||||
isEnabled = gc.VerboseErrors = !gc.VerboseErrors;
|
||||
|
||||
uow.SaveChanges();
|
||||
}
|
||||
uow.SaveChanges();
|
||||
|
||||
if ((bool)enabled) // This doesn't need to be duplicated inside the using block
|
||||
_guildsEnabled.Add(guildId);
|
||||
if (isEnabled) // This doesn't need to be duplicated inside the using block
|
||||
_guildsDisabled.TryRemove(guildId);
|
||||
else
|
||||
_guildsEnabled.TryRemove(guildId);
|
||||
_guildsDisabled.Add(guildId);
|
||||
|
||||
return (bool)enabled;
|
||||
return isEnabled;
|
||||
}
|
||||
}
|
@@ -12,7 +12,7 @@ public sealed class Attributes
|
||||
public bool IsFollower { get; set; }
|
||||
|
||||
[JsonPropertyName("last_charge_date")]
|
||||
public DateTime LastChargeDate { get; set; }
|
||||
public DateTime? LastChargeDate { get; set; }
|
||||
|
||||
[JsonPropertyName("last_charge_status")]
|
||||
public string LastChargeStatus { get; set; }
|
||||
@@ -57,7 +57,7 @@ public sealed class Address
|
||||
// // public CurrentlyEntitledTiers CurrentlyEntitledTiers { get; set; }
|
||||
// }
|
||||
|
||||
public sealed class PatreonResponse
|
||||
public sealed class PatreonMembersResponse
|
||||
{
|
||||
[JsonPropertyName("data")]
|
||||
public List<PatreonMember> Data { get; set; }
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user