re-adding/reworking voting capabilities

This commit is contained in:
Toastie 2025-03-25 14:58:02 +13:00
parent 69a02e0e15
commit 9fe75d930f
Signed by: toastie_t0ast
GPG key ID: 74226CF45EEE5AAF
24 changed files with 400 additions and 361 deletions

View file

@ -14,6 +14,7 @@ namespace EllieBot.VotesApi
public const string SchemeName = "AUTHORIZATION_SCHEME";
public const string DiscordsClaim = "DISCORDS_CLAIM";
public const string TopggClaim = "TOPGG_CLAIM";
public const string DiscordbotlistClaim = "DISCORDBOTLIST_CLAIM";
private readonly IConfiguration _conf;
@ -33,6 +34,9 @@ namespace EllieBot.VotesApi
if (_conf[ConfKeys.TOPGG_KEY] == Request.Headers["Authorization"].ToString().Trim())
claims.Add(new Claim(TopggClaim, "true"));
if (_conf[ConfKeys.DISCORDBOTLIST_KEY].Trim() == Request.Headers["Authorization"].ToString().Trim())
claims.Add(new Claim(DiscordbotlistClaim, "true"));
return Task.FromResult(AuthenticateResult.Success(new(new(new ClaimsIdentity(claims)), SchemeName)));
}

View file

@ -4,5 +4,6 @@
{
public const string DISCORDS_KEY = "DiscordsKey";
public const string TOPGG_KEY = "TopGGKey";
public const string DISCORDBOTLIST_KEY = "DiscordbotListKey";
}
}

View file

@ -0,0 +1,20 @@
namespace EllieBot.VotesApi
{
public class DiscordbotlistVoteWebhookModel
{
/// <summary>
/// The avatar hash of the user
/// </summary>
public string Avatar { get; set; }
/// <summary>
/// The username of the user who voted
/// </summary>
public string Username { get; set; }
/// <summary>
/// The ID of the user who voted
/// </summary>
public string Id { get; set; }
}
}

View file

@ -12,12 +12,6 @@
/// </summary>
public string Bot { get; set; }
/// <summary>
/// Contains totalVotes, votesMonth, votes24, hasVoted - a list of IDs of users who have voted this month, and
/// Voted24 - a list of IDs of users who have voted today
/// </summary>
public string Votes { get; set; }
/// <summary>
/// The type of event, whether it is a vote event or test event
/// </summary>

View file

@ -4,5 +4,6 @@
{
public const string DiscordsAuth = "DiscordsAuth";
public const string TopggAuth = "TopggAuth";
public const string DiscordbotlistAuth = "DiscordbotlistAuth";
}
}

View file

@ -1,33 +0,0 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using EllieBot.VotesApi.Services;
namespace EllieBot.VotesApi.Controllers
{
[ApiController]
[Route("[controller]")]
public class DiscordsController : ControllerBase
{
private readonly ILogger<DiscordsController> _logger;
private readonly IVotesCache _cache;
public DiscordsController(ILogger<DiscordsController> logger, IVotesCache cache)
{
_logger = logger;
_cache = cache;
}
[HttpGet("new")]
[Authorize(Policy = Policies.DiscordsAuth)]
public async Task<IEnumerable<Vote>> New()
{
var votes = await _cache.GetNewDiscordsVotesAsync();
if (votes.Count > 0)
_logger.LogInformation("Sending {NewDiscordsVotes} new discords votes", votes.Count);
return votes;
}
}
}

View file

@ -1,34 +0,0 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using EllieBot.VotesApi.Services;
namespace EllieBot.VotesApi.Controllers
{
[ApiController]
[Route("[controller]")]
public class TopGgController : ControllerBase
{
private readonly ILogger<TopGgController> _logger;
private readonly IVotesCache _cache;
public TopGgController(ILogger<TopGgController> logger, IVotesCache cache)
{
_logger = logger;
_cache = cache;
}
[HttpGet("new")]
[Authorize(Policy = Policies.TopggAuth)]
public async Task<IEnumerable<Vote>> New()
{
var votes = await _cache.GetNewTopGgVotesAsync();
if (votes.Count > 0)
_logger.LogInformation("Sending {NewTopggVotes} new topgg votes", votes.Count);
return votes;
}
}
}

View file

@ -2,33 +2,32 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using EllieBot.VotesApi.Services;
using EllieBot.GrpcVotesApi;
namespace EllieBot.VotesApi.Controllers
{
[ApiController]
public class WebhookController : ControllerBase
public class WebhookController(ILogger<WebhookController> logger, VoteService.VoteServiceClient client)
: ControllerBase
{
private readonly ILogger<WebhookController> _logger;
private readonly IVotesCache _votesCache;
public WebhookController(ILogger<WebhookController> logger, IVotesCache votesCache)
{
_logger = logger;
_votesCache = votesCache;
}
[HttpPost("/discordswebhook")]
[Authorize(Policy = Policies.DiscordsAuth)]
public async Task<IActionResult> DiscordsWebhook([FromBody] DiscordsVoteWebhookModel data)
{
_logger.LogInformation("User {UserId} has voted for Bot {BotId} on {Platform}",
if (data.Type != "vote")
return Ok();
logger.LogInformation("User {UserId} has voted for Bot {BotId} on {Platform}",
data.User,
data.Bot,
"discords.com");
await _votesCache.AddNewDiscordsVote(data.User);
await client.VoteReceivedAsync(new GrpcVoteData()
{
Type = VoteType.Discords,
UserId = data.User,
});
return Ok();
}
@ -36,12 +35,34 @@ namespace EllieBot.VotesApi.Controllers
[Authorize(Policy = Policies.TopggAuth)]
public async Task<IActionResult> TopggWebhook([FromBody] TopggVoteWebhookModel data)
{
_logger.LogInformation("User {UserId} has voted for Bot {BotId} on {Platform}",
logger.LogInformation("User {UserId} has voted for Bot {BotId} on {Platform}",
data.User,
data.Bot,
"top.gg");
await _votesCache.AddNewTopggVote(data.User);
await client.VoteReceivedAsync(new GrpcVoteData()
{
Type = VoteType.Topgg,
UserId = data.User,
});
return Ok();
}
[HttpPost("/discordbotlistwebhook")]
[Authorize(Policy = Policies.DiscordbotlistAuth)]
public async Task<IActionResult> DiscordbotlistWebhook([FromBody] DiscordbotlistVoteWebhookModel data)
{
logger.LogInformation("User {UserId} has voted for Bot on {Platform}",
data.Id,
"discordbotlist.com");
await client.VoteReceivedAsync(new GrpcVoteData()
{
Type = VoteType.Discordbotlist,
UserId = data.Id,
});
return Ok();
}
}

View file

@ -8,6 +8,11 @@
<ItemGroup>
<PackageReference Include="MorseCode.ITask" Version="2.0.3" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.2.0" />
<PackageReference Include="Grpc.AspNetCore" Version="2.70.0" />
</ItemGroup>
<ItemGroup>
<Protobuf Include="Protos/vote.proto" GrpcServices="Client" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,24 @@
syntax = "proto3";
package vote;
option csharp_namespace = "EllieBot.GrpcVotesApi";
enum VoteType {
TOPGG = 0;
DISCORDBOTLIST = 1;
DISCORDS = 2;
}
message GrpcVoteData {
string userId = 1;
VoteType type = 2;
}
message GrpcVoteResult {
}
service VoteService {
rpc VoteReceived (GrpcVoteData) returns (GrpcVoteResult);
}

View file

@ -1,100 +0,0 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading;
using MorseCode.ITask;
namespace EllieBot.VotesApi.Services
{
public class FileVotesCache : IVotesCache
{
// private const string STATS_FILE = "store/stats.json";
private const string TOPGG_FILE = "store/topgg.json";
private const string DISCORDS_FILE = "store/discords.json";
private readonly SemaphoreSlim _locker = new SemaphoreSlim(1, 1);
public FileVotesCache()
{
if (!Directory.Exists("store"))
Directory.CreateDirectory("store");
if (!File.Exists(TOPGG_FILE))
File.WriteAllText(TOPGG_FILE, "[]");
if (!File.Exists(DISCORDS_FILE))
File.WriteAllText(DISCORDS_FILE, "[]");
}
public ITask AddNewTopggVote(string userId)
=> AddNewVote(TOPGG_FILE, userId);
public ITask AddNewDiscordsVote(string userId)
=> AddNewVote(DISCORDS_FILE, userId);
private async ITask AddNewVote(string file, string userId)
{
await _locker.WaitAsync();
try
{
var votes = await GetVotesAsync(file);
votes.Add(userId);
await File.WriteAllTextAsync(file, JsonSerializer.Serialize(votes));
}
finally
{
_locker.Release();
}
}
public async ITask<IList<Vote>> GetNewTopGgVotesAsync()
{
var votes = await EvictTopggVotes();
return votes;
}
public async ITask<IList<Vote>> GetNewDiscordsVotesAsync()
{
var votes = await EvictDiscordsVotes();
return votes;
}
private ITask<List<Vote>> EvictTopggVotes()
=> EvictVotes(TOPGG_FILE);
private ITask<List<Vote>> EvictDiscordsVotes()
=> EvictVotes(DISCORDS_FILE);
private async ITask<List<Vote>> EvictVotes(string file)
{
await _locker.WaitAsync();
try
{
var ids = await GetVotesAsync(file);
await File.WriteAllTextAsync(file, "[]");
return ids?
.Select(x => (Ok: ulong.TryParse(x, out var r), Id: r))
.Where(x => x.Ok)
.Select(x => new Vote
{
UserId = x.Id
})
.ToList();
}
finally
{
_locker.Release();
}
}
private async ITask<IList<string>> GetVotesAsync(string file)
{
await using var fs = File.Open(file, FileMode.Open);
var votes = await JsonSerializer.DeserializeAsync<List<string>>(fs);
return votes;
}
}
}

View file

@ -1,13 +0,0 @@
using System.Collections.Generic;
using MorseCode.ITask;
namespace EllieBot.VotesApi.Services
{
public interface IVotesCache
{
ITask<IList<Vote>> GetNewTopGgVotesAsync();
ITask<IList<Vote>> GetNewDiscordsVotesAsync();
ITask AddNewTopggVote(string userId);
ITask AddNewDiscordsVote(string userId);
}
}

View file

@ -1,11 +1,12 @@
using Microsoft.AspNetCore.Authorization;
using System;
using Grpc.Core;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.OpenApi.Models;
using EllieBot.VotesApi.Services;
using EllieBot.GrpcVotesApi;
namespace EllieBot.VotesApi
{
@ -21,11 +22,17 @@ namespace EllieBot.VotesApi
public void ConfigureServices(IServiceCollection services)
{
services.AddControllers();
services.AddSingleton<IVotesCache, FileVotesCache>();
services.AddSwaggerGen(static c =>
{
c.SwaggerDoc("v1", new OpenApiInfo { Title = "EllieBot.VotesApi", Version = "v1" });
});
services.AddGrpcClient<VoteService.VoteServiceClient>(options =>
{
var grpcServiceUrl = Configuration["GrpcServiceUrl"]!;
options.Address = new Uri(grpcServiceUrl);
})
.ConfigureChannel((sp, c) =>
{
c.Credentials = ChannelCredentials.Insecure;
c.ServiceProvider = sp;
});
services
.AddAuthentication(opts =>
@ -40,8 +47,12 @@ namespace EllieBot.VotesApi
opts.DefaultPolicy = new AuthorizationPolicyBuilder(AuthHandler.SchemeName)
.RequireAssertion(static _ => false)
.Build();
opts.AddPolicy(Policies.DiscordsAuth, static policy => policy.RequireClaim(AuthHandler.DiscordsClaim));
opts.AddPolicy(Policies.TopggAuth, static policy => policy.RequireClaim(AuthHandler.TopggClaim));
opts.AddPolicy(Policies.DiscordsAuth,
static policy => policy.RequireClaim(AuthHandler.DiscordsClaim));
opts.AddPolicy(Policies.TopggAuth,
static policy => policy.RequireClaim(AuthHandler.TopggClaim));
opts.AddPolicy(Policies.DiscordbotlistAuth,
static policy => policy.RequireClaim(AuthHandler.DiscordbotlistClaim));
});
}
@ -51,8 +62,6 @@ namespace EllieBot.VotesApi
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
app.UseSwagger();
app.UseSwaggerUI(static c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "EllieBot.VotesApi v1"));
}
app.UseHttpsRedirection();

View file

@ -8,5 +8,6 @@
},
"DiscordsKey": "my_discords_key",
"TopGGKey": "my_topgg_key",
"DiscordBotListKey": "my_discordbotlist_key",
"AllowedHosts": "*"
}

View file

@ -124,6 +124,9 @@
<Link>_common\CoordinatorProtos\coordinator.proto</Link>
<GrpcServices>Client</GrpcServices>
</Protobuf>
<Protobuf Include="../EllieBot.VotesApi/Protos/*.proto">
<GrpcServices>Server</GrpcServices>
</Protobuf>
</ItemGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'GlobalEllie' ">

View file

@ -35,6 +35,7 @@ public partial class Gambling : GamblingModule<GamblingService>
private readonly RakebackService _rb;
private readonly IBotCache _cache;
private readonly CaptchaService _captchaService;
private readonly VoteRewardService _vrs;
public Gambling(
IGamblingService gs,
@ -50,7 +51,8 @@ public partial class Gambling : GamblingModule<GamblingService>
GamblingTxTracker gamblingTxTracker,
RakebackService rb,
IBotCache cache,
CaptchaService captchaService)
CaptchaService captchaService,
VoteRewardService vrs)
: base(configService)
{
_gs = gs;
@ -65,6 +67,7 @@ public partial class Gambling : GamblingModule<GamblingService>
_captchaService = captchaService;
_ps = patronage;
_rng = new EllieRandom();
_vrs = vrs;
_enUsCulture = new CultureInfo("en-US", false).NumberFormat;
_enUsCulture.NumberDecimalDigits = 0;
@ -131,6 +134,50 @@ public partial class Gambling : GamblingModule<GamblingService>
await ClaimTimely();
});
[Cmd]
public async Task Vote()
{
var reward = Config.VoteReward;
if (reward <= 0)
{
if (Config.Timely.Amount > 0 && Config.Timely.Cooldown > 0)
{
await Timely();
}
return;
}
if (await _vrs.LastVoted(ctx.User.Id) is { } remainder)
{
// Get correct time form remainder
var interaction = CreateRemindMeInteraction(remainder.TotalMilliseconds);
// Removes timely button if there is a timely reminder in DB
if (_service.UserHasTimelyReminder(ctx.User.Id))
{
interaction = null;
}
var now = DateTime.UtcNow;
var relativeTag = TimestampTag.FromDateTime(now.Add(remainder), TimestampTagStyles.Relative);
await Response().Pending(strs.vote_already_claimed(relativeTag)).Interaction(interaction).SendAsync();
return;
}
var (amount, msg) = await _service.GetAmountAndMessage(ctx.User.Id, reward);
var prepend = GetText(strs.vote_suggest(N(amount)));
msg = prepend + "\n\n" + msg;
var inter = CreateRemindMeInteraction(6);
await Response()
.Confirm(msg)
.Interaction(inter)
.SendAsync();
}
[Cmd]
public async Task Timely()
{
@ -138,10 +185,17 @@ public partial class Gambling : GamblingModule<GamblingService>
var period = Config.Timely.Cooldown;
if (val <= 0 || period <= 0)
{
if (Config.VoteReward > 0)
{
await Vote();
return;
}
await Response().Error(strs.timely_none).SendAsync();
return;
}
await _cs.AddAsync(ctx.User.Id, val, new("timely", "claim"));
if (Config.Timely.ProtType == TimelyProt.Button)
{
var interaction = CreateTimelyInteraction();
@ -149,7 +203,8 @@ public partial class Gambling : GamblingModule<GamblingService>
await msg.DeleteAsync();
return;
}
else if (Config.Timely.ProtType == TimelyProt.Captcha)
if (Config.Timely.ProtType == TimelyProt.Captcha)
{
var password = await _captchaService.GetUserCaptcha(ctx.User.Id);
@ -160,10 +215,10 @@ public partial class Gambling : GamblingModule<GamblingService>
var toSend = Response()
.File(stream, "timely.png");
#if GLOBAL_ELLIE
#if GLOBAL_NADEKO
if (_rng.Next(0, 8) == 0)
toSend = toSend
.Text("*[Sub on Patreon](https://patreon.com/elliebot) to remove captcha.*");
.Text("*[Sub on Patreon](https://patreon.com/nadekobot) to remove captcha.*");
#endif
var captchaMessage = await toSend.SendAsync();
@ -209,56 +264,19 @@ public partial class Gambling : GamblingModule<GamblingService>
var val = Config.Timely.Amount;
var boostGuilds = Config.BoostBonus.GuildIds ?? new();
var guildUsers = await boostGuilds
.Select(async gid =>
{
try
{
var guild = await _client.Rest.GetGuildAsync(gid, false);
var user = await _client.Rest.GetGuildUserAsync(gid, ctx.User.Id);
return (guild, user);
}
catch
{
return default;
}
})
.WhenAll();
var userInfo = guildUsers.FirstOrDefault(x => x.user?.PremiumSince is not null);
var booster = userInfo != default;
if (booster)
val += Config.BoostBonus.BaseTimelyBonus;
var patron = await _ps.GetPatronAsync(ctx.User.Id);
var percentBonus = (_ps.PercentBonus(patron) / 100f);
val += (int)(val * percentBonus);
var inter = CreateRemindMeInteraction(period);
await _cs.AddAsync(ctx.User.Id, val, new("timely", "claim"));
var prepend = GetText(strs.timely(N(val), period));
var (newVal, msg) = await _service.GetAmountAndMessage(ctx.User.Id, val);
var msg = GetText(strs.timely(N(val), period));
if (booster || percentBonus > float.Epsilon)
{
msg += "\n\n";
if (booster)
msg += $"*+{N(Config.BoostBonus.BaseTimelyBonus)} bonus for boosting {userInfo.guild}!*\n";
msg = prepend + "\n\n" + msg;
if (percentBonus > float.Epsilon)
msg +=
$"*+{percentBonus:P0} bonus for the [Patreon](https://patreon.com/elliebot) pledge! <:hart:746995901758832712>*";
await _cs.AddAsync(ctx.User.Id, newVal, new("timely", "claim"));
await Response().Confirm(msg).Interaction(inter).SendAsync();
}
else
await Response().Confirm(strs.timely(N(val), period)).Interaction(inter).SendAsync();
await Response().Confirm(msg).Interaction(inter).SendAsync();
}
[Cmd]
[OwnerOnly]
public async Task TimelyReset()
@ -911,7 +929,6 @@ public partial class Gambling : GamblingModule<GamblingService>
await Response().Embed(eb).SendAsync();
}
public enum GambleTestTarget
{
Slot,

View file

@ -63,6 +63,11 @@ public sealed partial class GamblingConfig : ICloneable<GamblingConfig>
This will work only if you've set up VotesApi and correct credentials for topgg and/or discords voting
""")]
public long VoteReward { get; set; } = 100;
[Comment("""
Id of the channel to send a message to after a user votes
""")]
public ulong? VoteFeedChannelId { get; set; }
[Comment("""Slot config""")]
public SlotsConfig Slots { get; set; }

View file

@ -1,10 +1,12 @@
#nullable disable
using System.Globalization;
using LinqToDB;
using LinqToDB.EntityFrameworkCore;
using EllieBot.Common.ModuleBehaviors;
using EllieBot.Db.Models;
using EllieBot.Modules.Gambling.Common;
using EllieBot.Modules.Gambling.Common.Connect4;
using EllieBot.Modules.Patronage;
namespace EllieBot.Modules.Gambling.Services;
@ -15,7 +17,8 @@ public class GamblingService : IEService, IReadyExecutor
private readonly DbService _db;
private readonly DiscordSocketClient _client;
private readonly IBotCache _cache;
private readonly GamblingConfigService _gss;
private readonly GamblingConfigService _gcs;
private readonly IPatronageService _ps;
private readonly EllieRandom _rng;
private static readonly TypedKey<long> _curDecayKey = new("currency:last_decay");
@ -24,12 +27,14 @@ public class GamblingService : IEService, IReadyExecutor
DbService db,
DiscordSocketClient client,
IBotCache cache,
GamblingConfigService gss)
GamblingConfigService gcs,
IPatronageService ps)
{
_db = db;
_client = client;
_cache = cache;
_gss = gss;
_gcs = gcs;
_ps = ps;
_rng = new EllieRandom();
}
@ -53,7 +58,7 @@ public class GamblingService : IEService, IReadyExecutor
{
try
{
var lifetime = _gss.Data.Currency.TransactionsLifetime;
var lifetime = _gcs.Data.Currency.TransactionsLifetime;
if (lifetime <= 0)
continue;
@ -61,7 +66,7 @@ public class GamblingService : IEService, IReadyExecutor
var days = TimeSpan.FromDays(lifetime);
await using var uow = _db.GetDbContext();
await uow.Set<CurrencyTransaction>()
.DeleteAsync(ct => ct.DateAdded == null || now - ct.DateAdded < days);
.DeleteAsync(ct => ct.DateAdded == null || now - ct.DateAdded < days);
}
catch (Exception ex)
{
@ -82,7 +87,7 @@ public class GamblingService : IEService, IReadyExecutor
{
try
{
var config = _gss.Data;
var config = _gcs.Data;
var maxDecay = config.Decay.MaxDecay;
if (config.Decay.Percent is <= 0 or > 1 || maxDecay < 0)
continue;
@ -113,14 +118,14 @@ public class GamblingService : IEService, IReadyExecutor
var decay = (double)config.Decay.Percent;
await uow.Set<DiscordUser>()
.Where(x => x.CurrencyAmount > config.Decay.MinThreshold && x.UserId != _client.CurrentUser.Id)
.UpdateAsync(old => new()
{
CurrencyAmount =
maxDecay > Sql.Round((old.CurrencyAmount * decay) - 0.5)
? (long)(old.CurrencyAmount - Sql.Round((old.CurrencyAmount * decay) - 0.5))
: old.CurrencyAmount - maxDecay
});
.Where(x => x.CurrencyAmount > config.Decay.MinThreshold && x.UserId != _client.CurrentUser.Id)
.UpdateAsync(old => new()
{
CurrencyAmount =
maxDecay > Sql.Round((old.CurrencyAmount * decay) - 0.5)
? (long)(old.CurrencyAmount - Sql.Round((old.CurrencyAmount * decay) - 0.5))
: old.CurrencyAmount - maxDecay
});
await uow.SaveChangesAsync();
@ -135,13 +140,14 @@ public class GamblingService : IEService, IReadyExecutor
}
}
private static readonly TypedKey<EconomyResult> _ecoKey = new("ellie:economy");
private static readonly TypedKey<EconomyResult> _ecoKey = new("nadeko:economy");
private static readonly SemaphoreSlim _timelyLock = new(1, 1);
private static TypedKey<Dictionary<ulong, long>> _timelyKey
= new("timely:claims");
public async Task<TimeSpan?> ClaimTimelyAsync(ulong userId, int period)
{
if (period == 0)
@ -190,8 +196,63 @@ public class GamblingService : IEService, IReadyExecutor
return db.GetTable<Reminder>()
.Any(x => x.UserId == userId
&& x.Type == ReminderType.Timely);
}
}
public async Task RemoveAllTimelyClaimsAsync()
=> await _cache.RemoveAsync(_timelyKey);
private string N(long amount)
=> CurrencyHelper.N(amount, CultureInfo.InvariantCulture, _gcs.Data.Currency.Sign);
public async Task<(long val, string msg)> GetAmountAndMessage(ulong userId, long originalAmount)
{
var gcsData = _gcs.Data;
var boostGuilds = gcsData.BoostBonus.GuildIds ?? [];
var guildUsers = await boostGuilds
.Select(async gid =>
{
try
{
var guild = _client.GetGuild(gid) as IGuild ?? await _client.Rest.GetGuildAsync(gid, false);
var user = await guild.GetUserAsync(gid) ?? await _client.Rest.GetGuildUserAsync(gid, userId);
return (guild, user);
}
catch
{
return default;
}
})
.WhenAll();
var userInfo = guildUsers.FirstOrDefault(x => x.user?.PremiumSince is not null);
var booster = userInfo != default;
if (booster)
originalAmount += gcsData.BoostBonus.BaseTimelyBonus;
var patron = await _ps.GetPatronAsync(userId);
var percentBonus = (_ps.PercentBonus(patron) / 100f);
originalAmount += (int)(originalAmount * percentBonus);
var msg = $"{N(originalAmount)} base reward.\n";
if (boostGuilds.Count > 0)
{
if (booster)
msg += $"✅ *+{N(gcsData.BoostBonus.BaseTimelyBonus)} bonus for boosting {userInfo.guild}!*\n";
else
msg += $"❌ +0 bonus for boosting {userInfo.guild}.\n";
}
if (_ps.GetConfig().IsEnabled)
{
if (percentBonus > float.Epsilon)
msg +=
$"✅ *+{percentBonus:P0} bonus for the [Patreon](https://patreon.com/nadekobot) pledge! <:hart:746995901758832712>*";
else
msg += $"❌ +0 bonus for the [Patreon](https://patreon.com/nadekobot) pledge.";
}
return (originalAmount, msg);
}
}

View file

@ -1,106 +1,128 @@
#nullable disable
using System.Globalization;
using Grpc.Core;
using EllieBot.Common.ModuleBehaviors;
using System.Text.Json;
using System.Text.Json.Serialization;
using EllieBot.GrpcApi;
using EllieBot.GrpcVotesApi;
namespace EllieBot.Modules.Gambling.Services;
public class VoteModel
public class VoteRewardService(
ShardData shardData,
GamblingConfigService gcs,
CurrencyService cs,
IBotCache cache,
DiscordSocketClient client,
IMessageSenderService sender
) : IEService, IReadyExecutor
{
[JsonPropertyName("userId")]
public ulong UserId { get; set; }
}
private TypedKey<DateTime> VoteKey(ulong userId)
=> new($"vote:{userId}");
public class VoteRewardService : IEService, IReadyExecutor
{
private readonly DiscordSocketClient _client;
private readonly IBotCreds _creds;
private readonly ICurrencyService _currencyService;
private readonly GamblingConfigService _gamb;
public VoteRewardService(
DiscordSocketClient client,
IBotCreds creds,
ICurrencyService currencyService,
GamblingConfigService gamb)
{
_client = client;
_creds = creds;
_currencyService = currencyService;
_gamb = gamb;
}
private Server? _app;
private IMessageChannel? _voteFeedChannel;
public async Task OnReadyAsync()
{
if (_client.ShardId != 0)
if (shardData.ShardId != 0)
return;
using var http = new HttpClient(new HttpClientHandler
var serverCreds = ServerCredentials.Insecure;
var ssd = VoteService.BindService(new VotesGrpcService(this));
_app = new()
{
AllowAutoRedirect = false,
ServerCertificateCustomValidationCallback = delegate { return true; }
Ports =
{
new("127.0.0.1", 59384, serverCreds),
}
};
_app.Services.Add(ssd);
_app.Start();
if (gcs.Data.VoteFeedChannelId is ulong cid)
{
_voteFeedChannel = await client.GetChannelAsync(cid) as IMessageChannel;
}
return;
}
public void SetVoiceChannel(IMessageChannel? channel)
{
gcs.ModifyConfig(c => { c.VoteFeedChannelId = channel?.Id; });
_voteFeedChannel = channel;
}
public async Task UserVotedAsync(ulong userId, VoteType requestType)
{
var gcsData = gcs.Data;
var reward = gcsData.VoteReward;
if (reward <= 0)
return;
var key = VoteKey(userId);
if (!await cache.AddAsync(key, DateTime.UtcNow, expiry: TimeSpan.FromHours(6)))
return;
await cs.AddAsync(userId, reward, new("vote", requestType.ToString()));
_ = Task.Run(async () =>
{
try
{
var user = await client.GetUserAsync(userId);
await sender.Response(user)
.Confirm(strs.vote_reward(N(reward)))
.SendAsync();
}
catch (Exception ex)
{
Log.Warning(ex, "Unable to send vote confirmation message to user {UserId}", userId);
}
});
while (true)
_ = Task.Run(async () =>
{
await Task.Delay(30000);
var topggKey = _creds.Votes?.TopggKey;
var topggServiceUrl = _creds.Votes?.TopggServiceUrl;
try
if (_voteFeedChannel is not null)
{
if (!string.IsNullOrWhiteSpace(topggKey) && !string.IsNullOrWhiteSpace(topggServiceUrl))
try
{
http.DefaultRequestHeaders.Authorization = new(topggKey);
var uri = new Uri(new(topggServiceUrl), "topgg/new");
var res = await http.GetStringAsync(uri);
var data = JsonSerializer.Deserialize<List<VoteModel>>(res);
if (data is { Count: > 0 })
{
var ids = data.Select(x => x.UserId).ToList();
await _currencyService.AddBulkAsync(ids,
_gamb.Data.VoteReward,
new("vote", "top.gg", "top.gg vote reward"));
Log.Information("Rewarding {Count} top.gg voters", ids.Count());
}
var user = await client.GetUserAsync(userId);
await _voteFeedChannel.SendMessageAsync(
$"{user} just received {strs.vote_reward(N(reward))} for voting!",
allowedMentions: AllowedMentions.None);
}
catch (Exception ex)
{
Log.Warning(ex, "Unable to send vote reward message to user {UserId}", userId);
}
}
catch (Exception ex)
{
Log.Error(ex, "Critical error loading top.gg vote rewards");
}
});
}
var discordsKey = _creds.Votes?.DiscordsKey;
var discordsServiceUrl = _creds.Votes?.DiscordsServiceUrl;
public async Task<TimeSpan?> LastVoted(ulong userId)
{
var key = VoteKey(userId);
var last = await cache.GetAsync(key);
return last.Match(
static x => DateTime.UtcNow.Subtract(x),
static _ => default(TimeSpan?));
}
try
{
if (!string.IsNullOrWhiteSpace(discordsKey) && !string.IsNullOrWhiteSpace(discordsServiceUrl))
{
http.DefaultRequestHeaders.Authorization = new(discordsKey);
var res = await http.GetStringAsync(new Uri(new(discordsServiceUrl), "discords/new"));
var data = JsonSerializer.Deserialize<List<VoteModel>>(res);
private string N(long amount)
=> CurrencyHelper.N(amount, CultureInfo.InvariantCulture, gcs.Data.Currency.Sign);
}
if (data is { Count: > 0 })
{
var ids = data.Select(x => x.UserId).ToList();
public sealed class VotesGrpcService(VoteRewardService vrs)
: VoteService.VoteServiceBase, IEService
{
[GrpcNoAuthRequired]
public override async Task<GrpcVoteResult> VoteReceived(GrpcVoteData request, ServerCallContext context)
{
await vrs.UserVotedAsync(ulong.Parse(request.UserId), request.Type);
await _currencyService.AddBulkAsync(ids,
_gamb.Data.VoteReward,
new("vote", "discords", "discords.com vote reward"));
Log.Information("Rewarding {Count} discords.com voters", ids.Count());
}
}
}
catch (Exception ex)
{
Log.Error(ex, "Critical error loading discords.com vote rewards");
}
}
return new();
}
}

View file

@ -0,0 +1,14 @@
using EllieBot.Modules.Gambling.Services;
namespace EllieBot.Modules.Owner;
[OwnerOnly]
public class Owner(VoteRewardService vrs) : EllieModule
{
[Cmd]
public async Task VoteFeed()
{
vrs.SetVoiceChannel(ctx.Channel);
await ctx.OkAsync();
}
}

View file

@ -1652,4 +1652,8 @@ linkfix:
- lfix
linkfixlist:
- linkfixlist
- lfixlist
- lfixlist
votefeed:
- votefeed
vote:
- vote

View file

@ -5181,5 +5181,14 @@ linkfixlist:
Lists all configured link fixes for the server.
ex:
- ''
params:
- { }
votefeed:
desc: |-
Shows bot votes in real time in the specified channel.
Omit channel to disable.
ex:
- '#votefeed'
- ''
params:
- { }

View file

@ -930,6 +930,7 @@
"autodc_disable": "I will no longer disconnect from the voice channel when there are no more tracks to play.",
"timely_none": "Bot owner didn't specify a timely reward.",
"timely_already_claimed": "You've already claimed your timely reward. You can get it again {0}.",
"vote_already_claimed": "You've already voted. You can vote again {0}.",
"timely": "You've claimed your {0}. You can claim again in {1}h",
"timely_set": "Users will be able to claim {0} every {1}h",
"timely_set_none": "Users will not be able to claim any timely currency.",
@ -1240,5 +1241,8 @@
"linkfix_list_title": "Link Fixes",
"linkfix_removed": "Link fix for {0} has been removed.",
"linkfix_not_found": "No link fix found for {0}.",
"notify_cant_set": "This event doesn't support origin channel, Please specify a channel"
"notify_cant_set": "This event doesn't support origin channel, Please specify a channel",
"vote_reward": "Thank you for voting! You've received {0}.",
"vote_suggest": "Voting for the bot will get you {0}!",
"vote_disabled": "Voting is disabled."
}