diff --git a/NadekoBot.sln b/NadekoBot.sln index 301a5e351..815e8a0bb 100644 --- a/NadekoBot.sln +++ b/NadekoBot.sln @@ -25,6 +25,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NadekoBot.Coordinator", "sr EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NadekoBot.Generators", "src\NadekoBot.Generators\NadekoBot.Generators.csproj", "{3BC3BDF8-1A0B-45EB-AB2B-C0891D4D37B8}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NadekoBot.VotesApi", "src\NadekoBot.VotesApi\NadekoBot.VotesApi.csproj", "{3BC82CFE-BEE7-451F-986B-17EDD1570C4F}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -62,6 +64,12 @@ Global {3BC3BDF8-1A0B-45EB-AB2B-C0891D4D37B8}.GlobalNadeko|Any CPU.Build.0 = Debug|Any CPU {3BC3BDF8-1A0B-45EB-AB2B-C0891D4D37B8}.Release|Any CPU.ActiveCfg = Release|Any CPU {3BC3BDF8-1A0B-45EB-AB2B-C0891D4D37B8}.Release|Any CPU.Build.0 = Release|Any CPU + {3BC82CFE-BEE7-451F-986B-17EDD1570C4F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3BC82CFE-BEE7-451F-986B-17EDD1570C4F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3BC82CFE-BEE7-451F-986B-17EDD1570C4F}.GlobalNadeko|Any CPU.ActiveCfg = Debug|Any CPU + {3BC82CFE-BEE7-451F-986B-17EDD1570C4F}.GlobalNadeko|Any CPU.Build.0 = Debug|Any CPU + {3BC82CFE-BEE7-451F-986B-17EDD1570C4F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3BC82CFE-BEE7-451F-986B-17EDD1570C4F}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -73,6 +81,7 @@ Global {DB448DD4-C97F-40E9-8BD3-F605FF1FF833} = {04929013-5BAB-42B0-B9B2-8F2BB8F16AF2} {AE9B7F8C-81D7-4401-83A3-643B38258374} = {04929013-5BAB-42B0-B9B2-8F2BB8F16AF2} {3BC3BDF8-1A0B-45EB-AB2B-C0891D4D37B8} = {04929013-5BAB-42B0-B9B2-8F2BB8F16AF2} + {3BC82CFE-BEE7-451F-986B-17EDD1570C4F} = {04929013-5BAB-42B0-B9B2-8F2BB8F16AF2} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {5F3F555C-855F-4BE8-B526-D062D3E8ACA4} diff --git a/src/NadekoBot.VotesApi/.dockerignore b/src/NadekoBot.VotesApi/.dockerignore new file mode 100644 index 000000000..cd967fc3a --- /dev/null +++ b/src/NadekoBot.VotesApi/.dockerignore @@ -0,0 +1,25 @@ +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/.idea +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/azds.yaml +**/bin +**/charts +**/docker-compose* +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +LICENSE +README.md \ No newline at end of file diff --git a/src/NadekoBot.VotesApi/.gitignore b/src/NadekoBot.VotesApi/.gitignore new file mode 100644 index 000000000..9ae80d359 --- /dev/null +++ b/src/NadekoBot.VotesApi/.gitignore @@ -0,0 +1 @@ +store/ \ No newline at end of file diff --git a/src/NadekoBot.VotesApi/Common/AuthHandler.cs b/src/NadekoBot.VotesApi/Common/AuthHandler.cs new file mode 100644 index 000000000..ee170ef50 --- /dev/null +++ b/src/NadekoBot.VotesApi/Common/AuthHandler.cs @@ -0,0 +1,44 @@ +using System.Collections.Generic; +using System.Security.Claims; +using System.Text.Encodings.Web; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using NadekoBot.VotesApi.Controllers; + +namespace NadekoBot.VotesApi +{ + public class AuthHandler : AuthenticationHandler + { + public const string SchemeName = "AUTHORIZATION_SCHEME"; + public const string DiscordsClaim = "DISCORDS_CLAIM"; + public const string TopggClaim = "TOPGG_CLAIM"; + + private readonly IConfiguration _conf; + + public AuthHandler(IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder, + ISystemClock clock, + IConfiguration conf) + : base(options, logger, encoder, clock) + { + _conf = conf; + } + + protected override Task HandleAuthenticateAsync() + { + var claims = new List(); + + if (_conf[ConfKeys.DISCORDS_KEY].Trim() == Request.Headers["Authorization"].ToString().Trim()) + claims.Add(new(DiscordsClaim, "true")); + + if (_conf[ConfKeys.TOPGG_KEY] == Request.Headers["Authorization"].ToString().Trim()) + claims.Add(new Claim(TopggClaim, "true")); + + return Task.FromResult(AuthenticateResult.Success(new(new(new ClaimsIdentity(claims)), SchemeName))); + } + } +} \ No newline at end of file diff --git a/src/NadekoBot.VotesApi/Common/ConfKeys.cs b/src/NadekoBot.VotesApi/Common/ConfKeys.cs new file mode 100644 index 000000000..c2d174e7b --- /dev/null +++ b/src/NadekoBot.VotesApi/Common/ConfKeys.cs @@ -0,0 +1,8 @@ +namespace NadekoBot.VotesApi +{ + public static class ConfKeys + { + public const string DISCORDS_KEY = "DiscordsKey"; + public const string TOPGG_KEY = "TopGGKey"; + } +} \ No newline at end of file diff --git a/src/NadekoBot.VotesApi/Common/DiscordsVoteWebhookModel.cs b/src/NadekoBot.VotesApi/Common/DiscordsVoteWebhookModel.cs new file mode 100644 index 000000000..be053e3b5 --- /dev/null +++ b/src/NadekoBot.VotesApi/Common/DiscordsVoteWebhookModel.cs @@ -0,0 +1,26 @@ +namespace NadekoBot.VotesApi +{ + public class DiscordsVoteWebhookModel + { + /// + /// The ID of the user who voted + /// + public string User { get; set; } + + /// + /// The ID of the bot which recieved the vote + /// + public string Bot { get; set; } + + /// + /// 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 + /// + public string Votes { get; set; } + + /// + /// The type of event, whether it is a vote event or test event + /// + public string Type { get; set; } + } +} \ No newline at end of file diff --git a/src/NadekoBot.VotesApi/Common/Policies.cs b/src/NadekoBot.VotesApi/Common/Policies.cs new file mode 100644 index 000000000..eefe7c2d0 --- /dev/null +++ b/src/NadekoBot.VotesApi/Common/Policies.cs @@ -0,0 +1,8 @@ +namespace NadekoBot.VotesApi +{ + public static class Policies + { + public const string DiscordsAuth = "DiscordsAuth"; + public const string TopggAuth = "TopggAuth"; + } +} \ No newline at end of file diff --git a/src/NadekoBot.VotesApi/Common/TopggVoteWebhookModel.cs b/src/NadekoBot.VotesApi/Common/TopggVoteWebhookModel.cs new file mode 100644 index 000000000..ba2332186 --- /dev/null +++ b/src/NadekoBot.VotesApi/Common/TopggVoteWebhookModel.cs @@ -0,0 +1,30 @@ +namespace NadekoBot.VotesApi +{ + public class TopggVoteWebhookModel + { + /// + /// Discord ID of the bot that received a vote. + /// + public string Bot { get; set; } + + /// + /// Discord ID of the user who voted. + /// + public string User { get; set; } + + /// + /// The type of the vote (should always be "upvote" except when using the test button it's "test"). + /// + public string Type { get; set; } + + /// + /// Whether the weekend multiplier is in effect, meaning users votes count as two. + /// + public bool Weekend { get; set; } + + /// + /// Query string params found on the /bot/:ID/vote page. Example: ?a=1&b=2. + /// + public string Query { get; set; } + } +} \ No newline at end of file diff --git a/src/NadekoBot.VotesApi/Controllers/DiscordsController.cs b/src/NadekoBot.VotesApi/Controllers/DiscordsController.cs new file mode 100644 index 000000000..d425a72ce --- /dev/null +++ b/src/NadekoBot.VotesApi/Controllers/DiscordsController.cs @@ -0,0 +1,33 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using NadekoBot.VotesApi.Services; + +namespace NadekoBot.VotesApi.Controllers +{ + [ApiController] + [Route("[controller]")] + public class DiscordsController : ControllerBase + { + private readonly ILogger _logger; + private readonly IVotesCache _cache; + + public DiscordsController(ILogger logger, IVotesCache cache) + { + _logger = logger; + _cache = cache; + } + + [HttpGet("new")] + [Authorize(Policy = Policies.DiscordsAuth)] + public async Task> New() + { + var votes = await _cache.GetNewDiscordsVotesAsync(); + if(votes.Count > 0) + _logger.LogInformation("Sending {NewDiscordsVotes} new discords votes.", votes.Count); + return votes; + } + } +} \ No newline at end of file diff --git a/src/NadekoBot.VotesApi/Controllers/TopGgController.cs b/src/NadekoBot.VotesApi/Controllers/TopGgController.cs new file mode 100644 index 000000000..857c8474a --- /dev/null +++ b/src/NadekoBot.VotesApi/Controllers/TopGgController.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using NadekoBot.VotesApi.Services; + +namespace NadekoBot.VotesApi.Controllers +{ + [ApiController] + [Route("[controller]")] + public class TopGgController : ControllerBase + { + private readonly ILogger _logger; + private readonly IVotesCache _cache; + + public TopGgController(ILogger logger, IVotesCache cache) + { + _logger = logger; + _cache = cache; + } + + [HttpGet("new")] + [Authorize(Policy = Policies.TopggAuth)] + public async Task> New() + { + var votes = await _cache.GetNewTopGgVotesAsync(); + if(votes.Count > 0) + _logger.LogInformation("Sending {NewTopggVotes} new topgg votes.", votes.Count); + + return votes; + } + } +} \ No newline at end of file diff --git a/src/NadekoBot.VotesApi/Controllers/WebhookController.cs b/src/NadekoBot.VotesApi/Controllers/WebhookController.cs new file mode 100644 index 000000000..fc759be31 --- /dev/null +++ b/src/NadekoBot.VotesApi/Controllers/WebhookController.cs @@ -0,0 +1,52 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using NadekoBot.VotesApi.Services; + +namespace NadekoBot.VotesApi.Controllers +{ + [ApiController] + public class WebhookController : ControllerBase + { + private readonly ILogger _logger; + private readonly IVotesCache _votesCache; + private readonly IConfiguration _conf; + + public WebhookController(ILogger logger, IVotesCache votesCache, IConfiguration conf) + { + _logger = logger; + _votesCache = votesCache; + _conf = conf; + } + + [HttpPost("/discordswebhook")] + [Authorize(Policy = Policies.DiscordsAuth)] + public async Task DiscordsWebhook([FromBody]DiscordsVoteWebhookModel data) + { + + _logger.LogInformation("User {UserId} has voted for Bot {BotId} on {Platform}", + data.User, + data.Bot, + "discords.com"); + + await _votesCache.AddNewDiscordsVote(data.User); + return Ok(); + } + + [HttpPost("/topggwebhook")] + [Authorize(Policy = Policies.TopggAuth)] + public async Task TopggWebhook([FromBody] TopggVoteWebhookModel data) + { + _logger.LogInformation("User {UserId} has voted for Bot {BotId} on {Platform}", + data.User, + data.Bot, + "top.gg"); + + await _votesCache.AddNewTopggVote(data.User); + return Ok(); + } + } +} \ No newline at end of file diff --git a/src/NadekoBot.VotesApi/Dockerfile b/src/NadekoBot.VotesApi/Dockerfile new file mode 100644 index 000000000..5fb659c9c --- /dev/null +++ b/src/NadekoBot.VotesApi/Dockerfile @@ -0,0 +1,20 @@ +FROM mcr.microsoft.com/dotnet/aspnet:5.0 AS base +WORKDIR /app +EXPOSE 80 +EXPOSE 443 + +FROM mcr.microsoft.com/dotnet/sdk:5.0 AS build +WORKDIR /src +COPY ["src/NadekoBot.VotesApi/NadekoBot.VotesApi.csproj", "NadekoBot.VotesApi/"] +RUN dotnet restore "src/NadekoBot.VotesApi/NadekoBot.VotesApi.csproj" +COPY . . +WORKDIR "/src/NadekoBot.VotesApi" +RUN dotnet build "NadekoBot.VotesApi.csproj" -c Release -o /app/build + +FROM build AS publish +RUN dotnet publish "NadekoBot.VotesApi.csproj" -c Release -o /app/publish + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "NadekoBot.VotesApi.dll"] diff --git a/src/NadekoBot.VotesApi/NadekoBot.VotesApi.csproj b/src/NadekoBot.VotesApi/NadekoBot.VotesApi.csproj new file mode 100644 index 000000000..92278253a --- /dev/null +++ b/src/NadekoBot.VotesApi/NadekoBot.VotesApi.csproj @@ -0,0 +1,13 @@ + + + + net5.0 + Linux + + + + + + + + diff --git a/src/NadekoBot.VotesApi/Program.cs b/src/NadekoBot.VotesApi/Program.cs new file mode 100644 index 000000000..e8eddd1a8 --- /dev/null +++ b/src/NadekoBot.VotesApi/Program.cs @@ -0,0 +1,23 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace NadekoBot.VotesApi +{ + public class Program + { + public static void Main(string[] args) + { + CreateHostBuilder(args).Build().Run(); + } + + public static IHostBuilder CreateHostBuilder(string[] args) => + Host.CreateDefaultBuilder(args) + .ConfigureWebHostDefaults(webBuilder => { webBuilder.UseStartup(); }); + } +} \ No newline at end of file diff --git a/src/NadekoBot.VotesApi/Properties/launchSettings.json b/src/NadekoBot.VotesApi/Properties/launchSettings.json new file mode 100644 index 000000000..e50f94c52 --- /dev/null +++ b/src/NadekoBot.VotesApi/Properties/launchSettings.json @@ -0,0 +1,31 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:16451", + "sslPort": 44323 + } + }, + "profiles": { + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "NadekoBot.VotesApi": { + "commandName": "Project", + "dotnetRunMessages": "true", + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:5001;http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/NadekoBot.VotesApi/README.md b/src/NadekoBot.VotesApi/README.md new file mode 100644 index 000000000..8cffb237e --- /dev/null +++ b/src/NadekoBot.VotesApi/README.md @@ -0,0 +1,46 @@ +## Votes Api + +This api is used if you want your bot to be able to reward users who vote for it on discords.com or top.gg + +#### [GET] `/discords/new` + Get the discords votes received after previous call to this endpoint. + Input full url of this endpoint in your creds.yml file under Discords url field. + For example "https://api.my.cool.bot/discords/new" +#### [GET] `/topgg/new` + Get the topgg votes received after previous call to this endpoint. + Input full url of this endpoint in your creds.yml file under Topgg url field. + For example "https://api.my.cool.bot/topgg/new" + +#### [POST] `/discordswebhook` + Input this endpoint as the webhook on discords.com bot edit page + model: https://docs.botsfordiscord.com/methods/receiving-votes + For example "https://api.my.cool.bot/topggwebhook" +#### [POST] `/topggwebhook` + Input this endpoint as the webhook https://top.gg/bot/:your-bot-id/webhooks (replace :your-bot-id with your bot's id) + model: https://docs.top.gg/resources/webhooks/#schema + For example "https://api.my.cool.bot/discordswebhook" + +Input your super-secret header value in appsettings.json's DiscordsKey and TopGGKey fields +They must match your DiscordsKey and TopGG key respectively, as well as your secrets in the discords.com and top.gg webhook setup pages + +Full Example: + +⚠ Change TopggKey and DiscordsKey to a secure long string +⚠ You can use https://www.random.org/strings/?num=1&len=20&digits=on&upperalpha=on&loweralpha=on&unique=on&format=html&rnd=new to generate it + +`creds.yml` +```yml +votes: + TopggServiceUrl: "https://api.my.cool.bot/topgg" + TopggKey: "my_topgg_key" + DiscordsServiceUrl: "https://api.my.cool.bot/discords" + DiscordsKey: "my_discords_key" +``` + +`appsettings.json` +```json +... + "DiscordsKey": "my_discords_key", + "TopGGKey": "my_topgg_key", +... +``` \ No newline at end of file diff --git a/src/NadekoBot.VotesApi/Services/FileVotesCache.cs b/src/NadekoBot.VotesApi/Services/FileVotesCache.cs new file mode 100644 index 000000000..8729d309f --- /dev/null +++ b/src/NadekoBot.VotesApi/Services/FileVotesCache.cs @@ -0,0 +1,105 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading; +using MorseCode.ITask; + +namespace NadekoBot.VotesApi.Services +{ + public class FileVotesCache : IVotesCache + { + private const string statsFile = "store/stats.json"; + private const string topggFile = "store/topgg.json"; + private const string discordsFile = "store/discords.json"; + + private readonly SemaphoreSlim locker = new SemaphoreSlim(1, 1); + + public FileVotesCache() + { + if (!Directory.Exists("store")) + Directory.CreateDirectory("store"); + + if(!File.Exists(topggFile)) + File.WriteAllText(topggFile, "[]"); + + if(!File.Exists(discordsFile)) + File.WriteAllText(discordsFile, "[]"); + } + + public ITask AddNewTopggVote(string userId) + { + return AddNewVote(topggFile, userId); + } + + public ITask AddNewDiscordsVote(string userId) + { + return AddNewVote(discordsFile, 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> GetNewTopGgVotesAsync() + { + var votes = await EvictTopggVotes(); + return votes; + } + + public async ITask> GetNewDiscordsVotesAsync() + { + var votes = await EvictDiscordsVotes(); + return votes; + } + + private ITask> EvictTopggVotes() + => EvictVotes(topggFile); + + private ITask> EvictDiscordsVotes() + => EvictVotes(discordsFile); + + private async ITask> 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> GetVotesAsync(string file) + { + await using var fs = File.Open(file, FileMode.Open); + var votes = await JsonSerializer.DeserializeAsync>(fs); + return votes; + } + } +} \ No newline at end of file diff --git a/src/NadekoBot.VotesApi/Services/IVotesCache.cs b/src/NadekoBot.VotesApi/Services/IVotesCache.cs new file mode 100644 index 000000000..61b8bbc62 --- /dev/null +++ b/src/NadekoBot.VotesApi/Services/IVotesCache.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; +using MorseCode.ITask; + +namespace NadekoBot.VotesApi.Services +{ + public interface IVotesCache + { + ITask> GetNewTopGgVotesAsync(); + ITask> GetNewDiscordsVotesAsync(); + ITask AddNewTopggVote(string userId); + ITask AddNewDiscordsVote(string userId); + } +} \ No newline at end of file diff --git a/src/NadekoBot.VotesApi/Startup.cs b/src/NadekoBot.VotesApi/Startup.cs new file mode 100644 index 000000000..1d0320b33 --- /dev/null +++ b/src/NadekoBot.VotesApi/Startup.cs @@ -0,0 +1,69 @@ +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 NadekoBot.VotesApi.Services; + +namespace NadekoBot.VotesApi +{ + public class Startup + { + public Startup(IConfiguration configuration) + { + Configuration = configuration; + } + + public IConfiguration Configuration { get; } + + // This method gets called by the runtime. Use this method to add services to the container. + public void ConfigureServices(IServiceCollection services) + { + services.AddControllers(); + services.AddSingleton(); + services.AddSwaggerGen(c => + { + c.SwaggerDoc("v1", new OpenApiInfo { Title = "NadekoBot.VotesApi", Version = "v1" }); + }); + + services + .AddAuthentication(opts => + { + opts.DefaultScheme = AuthHandler.SchemeName; + opts.AddScheme(AuthHandler.SchemeName, AuthHandler.SchemeName); + }); + + services + .AddAuthorization(opts => + { + opts.DefaultPolicy = new AuthorizationPolicyBuilder(AuthHandler.SchemeName) + .RequireAssertion(x => false) + .Build(); + opts.AddPolicy(Policies.DiscordsAuth, policy => policy.RequireClaim(AuthHandler.DiscordsClaim)); + opts.AddPolicy(Policies.TopggAuth, policy => policy.RequireClaim(AuthHandler.TopggClaim)); + }); + } + + // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. + public void Configure(IApplicationBuilder app, IWebHostEnvironment env) + { + if (env.IsDevelopment()) + { + app.UseDeveloperExceptionPage(); + app.UseSwagger(); + app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "NadekoBot.VotesApi v1")); + } + + app.UseHttpsRedirection(); + + app.UseRouting(); + + app.UseAuthentication(); + app.UseAuthorization(); + + app.UseEndpoints(endpoints => { endpoints.MapControllers(); }); + } + } +} \ No newline at end of file diff --git a/src/NadekoBot.VotesApi/WeatherForecast.cs b/src/NadekoBot.VotesApi/WeatherForecast.cs new file mode 100644 index 000000000..20f8ffb14 --- /dev/null +++ b/src/NadekoBot.VotesApi/WeatherForecast.cs @@ -0,0 +1,9 @@ +using System; + +namespace NadekoBot.VotesApi +{ + public class Vote + { + public ulong UserId { get; set; } + } +} \ No newline at end of file diff --git a/src/NadekoBot.VotesApi/appsettings.Development.json b/src/NadekoBot.VotesApi/appsettings.Development.json new file mode 100644 index 000000000..8983e0fc1 --- /dev/null +++ b/src/NadekoBot.VotesApi/appsettings.Development.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + } +} diff --git a/src/NadekoBot.VotesApi/appsettings.json b/src/NadekoBot.VotesApi/appsettings.json new file mode 100644 index 000000000..7b5f330e3 --- /dev/null +++ b/src/NadekoBot.VotesApi/appsettings.json @@ -0,0 +1,12 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "DiscordsKey": "my_discords_key", + "TopGGKey": "my_topgg_key", + "AllowedHosts": "*" +} diff --git a/src/NadekoBot/Common/Creds.cs b/src/NadekoBot/Common/Creds.cs index f17cf7f70..28354fdaf 100644 --- a/src/NadekoBot/Common/Creds.cs +++ b/src/NadekoBot/Common/Creds.cs @@ -13,7 +13,7 @@ namespace NadekoBot.Common OwnerIds = new List(); TotalShards = 1; GoogleApiKey = string.Empty; - Votes = new(string.Empty, string.Empty); + Votes = new(string.Empty, string.Empty, string.Empty, string.Empty); Patreon = new(string.Empty, string.Empty, string.Empty, string.Empty); BotListToken = string.Empty; CleverbotApiKey = string.Empty; @@ -77,11 +77,6 @@ Change only if you've changed the coordinator address or port.")] public string PatreonCampaignId => Patreon?.CampaignId; [YamlIgnore] public string PatreonAccessToken => Patreon?.AccessToken; - - [YamlIgnore] - public string VotesUrl => Votes?.Url; - [YamlIgnore] - public string VotesToken => Votes.Key; [Comment(@"Api key obtained on https://rapidapi.com (go to MyApps -> Add New App -> Enter Name -> Application key)")] public string RapidApiKey { get; set; } @@ -143,19 +138,44 @@ Windows default ClientSecret = clientSecret; CampaignId = campaignId; } + + public PatreonSettings() + { + + } } public sealed record VotesSettings { - [Comment(@"")] - public string Url { get; set; } - [Comment(@"")] - public string Key { get; set; } + [Comment(@"top.gg votes service url +This is the url of your instance of the NadekoBot.Votes api +Example: https://votes.my.cool.bot.com")] + public string TopggServiceUrl { get; set; } + + [Comment(@"Authorization header value sent to the TopGG service url with each request +This should be equivalent to the TopggKey in your NadekoBot.Votes api appsettings.json file")] + public string TopggKey { get; set; } + + [Comment(@"discords.com votes service url +This is the url of your instance of the NadekoBot.Votes api +Example: https://votes.my.cool.bot.com")] + public string DiscordsServiceUrl { get; set; } + + [Comment(@"Authorization header value sent to the Discords service url with each request +This should be equivalent to the DiscordsKey in your NadekoBot.Votes api appsettings.json file")] + public string DiscordsKey { get; set; } - public VotesSettings(string url, string key) + public VotesSettings() { - Url = url; - Key = key; + + } + + public VotesSettings(string topggServiceUrl, string topggKey, string discordsServiceUrl, string discordsKey) + { + TopggServiceUrl = topggServiceUrl; + TopggKey = topggKey; + DiscordsServiceUrl = discordsServiceUrl; + DiscordsKey = discordsKey; } } diff --git a/src/NadekoBot/Common/IBotCredentials.cs b/src/NadekoBot/Common/IBotCredentials.cs index 485326121..f759bbcb3 100644 --- a/src/NadekoBot/Common/IBotCredentials.cs +++ b/src/NadekoBot/Common/IBotCredentials.cs @@ -20,8 +20,7 @@ namespace NadekoBot string PatreonCampaignId { get; } string CleverbotApiKey { get; } RestartConfig RestartCommand { get; } - string VotesUrl { get; } - string VotesToken { get; } + Creds.VotesSettings Votes { get; } string BotListToken { get; } string RedisOptions { get; } string LocationIqApiKey { get; } diff --git a/src/NadekoBot/Common/Yml/Yaml.cs b/src/NadekoBot/Common/Yml/Yaml.cs index d056ad1c6..86ecd974d 100644 --- a/src/NadekoBot/Common/Yml/Yaml.cs +++ b/src/NadekoBot/Common/Yml/Yaml.cs @@ -20,6 +20,7 @@ namespace NadekoBot.Common.Yml .WithTypeConverter(new Rgba32Converter()) .WithTypeConverter(new CultureInfoConverter()) .WithTypeConverter(new UriConverter()) + .IgnoreUnmatchedProperties() .Build(); } } \ No newline at end of file diff --git a/src/NadekoBot/Modules/Gambling/Common/GamblingConfig.cs b/src/NadekoBot/Modules/Gambling/Common/GamblingConfig.cs index 435a3e3d5..31b02b17c 100644 --- a/src/NadekoBot/Modules/Gambling/Common/GamblingConfig.cs +++ b/src/NadekoBot/Modules/Gambling/Common/GamblingConfig.cs @@ -23,7 +23,7 @@ namespace NadekoBot.Modules.Gambling.Common } [Comment(@"DO NOT CHANGE")] - public int Version { get; set; } = 1; + public int Version { get; set; } = 2; [Comment(@"Currency settings")] public CurrencyConfig Currency { get; set; } @@ -60,6 +60,10 @@ Set 0 for unlimited")] [Comment(@"Amount of currency selfhosters will get PER pledged dollar CENT. 1 = 100 currency per $. Used almost exclusively on public nadeko.")] public decimal PatreonCurrencyPerCent { get; set; } = 1; + + [Comment(@"Currency reward per vote. +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; } public class CurrencyConfig diff --git a/src/NadekoBot/Modules/Gambling/Services/CurrencyEventsService.cs b/src/NadekoBot/Modules/Gambling/Services/CurrencyEventsService.cs index a1ea1374c..3bd3ad1a2 100644 --- a/src/NadekoBot/Modules/Gambling/Services/CurrencyEventsService.cs +++ b/src/NadekoBot/Modules/Gambling/Services/CurrencyEventsService.cs @@ -8,8 +8,6 @@ using System.Threading.Tasks; using System; using NadekoBot.Services.Database.Models; using System.Net.Http; -using Newtonsoft.Json; -using System.Linq; using NadekoBot.Modules.Gambling.Services; using Serilog; @@ -17,76 +15,22 @@ namespace NadekoBot.Modules.Gambling.Services { public class CurrencyEventsService : INService { - public class VoteModel - { - public ulong User { get; set; } - public long Date { get; set; } - } private readonly DiscordSocketClient _client; private readonly ICurrencyService _cs; - private readonly IBotCredentials _creds; - private readonly IHttpClientFactory _http; private readonly GamblingConfigService _configService; + private readonly ConcurrentDictionary _events = new ConcurrentDictionary(); - public CurrencyEventsService(DiscordSocketClient client, - IBotCredentials creds, ICurrencyService cs, - IHttpClientFactory http, GamblingConfigService configService) + + public CurrencyEventsService( + DiscordSocketClient client, + ICurrencyService cs, + GamblingConfigService configService) { _client = client; _cs = cs; - _creds = creds; - _http = http; _configService = configService; - - if (_client.ShardId == 0) - { - Task t = BotlistUpvoteLoop(); - } - } - - // todo future use votes api directly? - private async Task BotlistUpvoteLoop() - { - if (string.IsNullOrWhiteSpace(_creds.VotesUrl)) - return; - - while (true) - { - await Task.Delay(TimeSpan.FromHours(1)).ConfigureAwait(false); - await TriggerVoteCheck().ConfigureAwait(false); - } - } - - private async Task TriggerVoteCheck() - { - try - { - using (var req = new HttpRequestMessage(HttpMethod.Get, _creds.VotesUrl)) - { - if (!string.IsNullOrWhiteSpace(_creds.VotesToken)) - req.Headers.Add("Authorization", _creds.VotesToken); - using (var http = _http.CreateClient()) - using (var res = await http.SendAsync(req).ConfigureAwait(false)) - { - if (!res.IsSuccessStatusCode) - { - Log.Warning("Botlist API not reached."); - return; - } - var resStr = await res.Content.ReadAsStringAsync().ConfigureAwait(false); - var ids = JsonConvert.DeserializeObject(resStr) - .Select(x => x.User) - .Distinct(); - await _cs.AddBulkAsync(ids, ids.Select(x => "Voted - "), ids.Select(x => 10L), true).ConfigureAwait(false); - } - } - } - catch (Exception ex) - { - Log.Warning(ex, "Error in TriggerVoteCheck"); - } } public async Task TryCreateEventAsync(ulong guildId, ulong channelId, CurrencyEvent.Type type, @@ -127,6 +71,7 @@ namespace NadekoBot.Modules.Gambling.Services return false; } } + return added; } @@ -136,4 +81,4 @@ namespace NadekoBot.Modules.Gambling.Services return Task.CompletedTask; } } -} +} \ No newline at end of file diff --git a/src/NadekoBot/Modules/Gambling/Services/GamblingConfigService.cs b/src/NadekoBot/Modules/Gambling/Services/GamblingConfigService.cs index 7767d379b..4e490babd 100644 --- a/src/NadekoBot/Modules/Gambling/Services/GamblingConfigService.cs +++ b/src/NadekoBot/Modules/Gambling/Services/GamblingConfigService.cs @@ -63,6 +63,14 @@ namespace NadekoBot.Modules.Gambling.Services c.Version = 2; }); } + + if (_data.Version < 3) + { + ModifyConfig(c => + { + c.VoteReward = 100; + }); + } } } } \ No newline at end of file diff --git a/src/NadekoBot/Modules/Gambling/Services/VoteRewardService.cs b/src/NadekoBot/Modules/Gambling/Services/VoteRewardService.cs new file mode 100644 index 000000000..cae63f93c --- /dev/null +++ b/src/NadekoBot/Modules/Gambling/Services/VoteRewardService.cs @@ -0,0 +1,122 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading.Tasks; +using NadekoBot.Common.ModuleBehaviors; +using NadekoBot.Services; +using Discord.WebSocket; +using Serilog; + +namespace NadekoBot.Modules.Gambling.Services +{ + public class VoteModel + { + [JsonPropertyName("userId")] + public ulong UserId { get; set; } + } + + public class VoteRewardService : INService, IReadyExecutor + { + private readonly DiscordSocketClient _client; + private readonly IBotCredentials _creds; + private readonly IHttpClientFactory _httpClientFactory; + private readonly ICurrencyService _currencyService; + private readonly GamblingConfigService _gamb; + private HttpClient _http; + + public VoteRewardService( + DiscordSocketClient client, + IBotCredentials creds, + IHttpClientFactory httpClientFactory, + ICurrencyService currencyService, + GamblingConfigService gamb) + { + _client = client; + _creds = creds; + _httpClientFactory = httpClientFactory; + _currencyService = currencyService; + _gamb = gamb; + } + + public async Task OnReadyAsync() + { + if (_client.ShardId != 0) + return; + + _http = new HttpClient(new HttpClientHandler() + { + AllowAutoRedirect = false, + ServerCertificateCustomValidationCallback = delegate { return true; } + }); + + while (true) + { + await Task.Delay(30000); + + var topggKey = _creds.Votes?.TopggKey; + var topggServiceUrl = _creds.Votes?.TopggServiceUrl; + + try + { + if (!string.IsNullOrWhiteSpace(topggKey) + && !string.IsNullOrWhiteSpace(topggServiceUrl)) + { + _http.DefaultRequestHeaders.Authorization = new(topggKey); + var uri = new Uri(new(topggServiceUrl), "topgg/new"); + var res = await _http.GetStringAsync(uri); + var data = JsonSerializer.Deserialize>(res); + + if (data is { Count: > 0 }) + { + var ids = data.Select(x => x.UserId).ToList(); + + await _currencyService.AddBulkAsync(ids, + data.Select(_ => "top.gg vote reward"), + data.Select(x => _gamb.Data.VoteReward), + true); + + Log.Information("Rewarding {Count} top.gg voters", ids.Count()); + } + } + } + catch (Exception ex) + { + Log.Error(ex, "Critical error loading top.gg vote rewards."); + } + + var discordsKey = _creds.Votes?.DiscordsKey; + var discordsServiceUrl = _creds.Votes?.DiscordsServiceUrl; + + 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>(res); + + if (data is { Count: > 0 }) + { + var ids = data.Select(x => x.UserId).ToList(); + + await _currencyService.AddBulkAsync(ids, + data.Select(_ => "discords.com vote reward"), + data.Select(x => _gamb.Data.VoteReward), + true); + + Log.Information("Rewarding {Count} discords.com voters", ids.Count()); + } + } + } + catch (Exception ex) + { + Log.Error(ex, "Critical error loading discords.com vote rewards."); + } + } + } + } +} \ No newline at end of file diff --git a/src/NadekoBot/Services/Impl/BotCredsProvider.cs b/src/NadekoBot/Services/Impl/BotCredsProvider.cs index 82aaef28b..f2d5f0ae3 100644 --- a/src/NadekoBot/Services/Impl/BotCredsProvider.cs +++ b/src/NadekoBot/Services/Impl/BotCredsProvider.cs @@ -128,7 +128,10 @@ namespace NadekoBot.Services null, null, oldCreds.PatreonCampaignId), - Votes = new Creds.VotesSettings(oldCreds.VotesUrl, oldCreds.VotesToken), + Votes = new(oldCreds.VotesUrl, + oldCreds.VotesToken, + string.Empty, + string.Empty), BotListToken = oldCreds.BotListToken, RedisOptions = oldCreds.RedisOptions, LocationIqApiKey = oldCreds.LocationIqApiKey, @@ -141,6 +144,17 @@ namespace NadekoBot.Services Log.Warning("Data from credentials.json has been moved to creds.yml\nPlease inspect your creds.yml for correctness"); } + + if (File.Exists(_credsFileName)) + { + var creds = Yaml.Deserializer.Deserialize(File.ReadAllText(_credsFileName)); + if (creds.Version <= 1) + { + creds.Version = 2; + File.WriteAllText(_credsFileName, Yaml.Serializer.Serialize(creds)); + } + } + } public Creds GetCreds() => _creds; diff --git a/src/NadekoBot/creds_example.yml b/src/NadekoBot/creds_example.yml index 5bad7a228..05f9cd773 100644 --- a/src/NadekoBot/creds_example.yml +++ b/src/NadekoBot/creds_example.yml @@ -14,8 +14,20 @@ totalShards: 1 googleApiKey: '' # Settings for voting system for discordbots. Meant for use on global Nadeko. votes: - url: '' - key: '' +# top.gg votes service url +# This is the url of your instance of the NadekoBot.Votes api +# Example: https://votes.my.cool.bot.com + topggServiceUrl: '' + # Authorization header value sent to the TopGG service url with each request +# This should be equivalent to the TopggKey in your NadekoBot.Votes api appsettings.json file + topggKey: '' + # discords.com votes service url +# This is the url of your instance of the NadekoBot.Votes api +# Example: https://votes.my.cool.bot.com + discordsServiceUrl: '' + # Authorization header value sent to the Discords service url with each request +# This should be equivalent to the DiscordsKey in your NadekoBot.Votes api appsettings.json file + discordsKey: '' # Patreon auto reward system settings. # go to https://www.patreon.com/portal -> my clients -> create client patreon: diff --git a/src/NadekoBot/data/gambling.yml b/src/NadekoBot/data/gambling.yml index 636e31592..9482d68d8 100644 --- a/src/NadekoBot/data/gambling.yml +++ b/src/NadekoBot/data/gambling.yml @@ -237,3 +237,6 @@ waifu: # Amount of currency selfhosters will get PER pledged dollar CENT. # 1 = 100 currency per $. Used almost exclusively on public nadeko. patreonCurrencyPerCent: 1 +# Currency reward per vote. +# This will work only if you've set up VotesApi and correct credentials for topgg and/or discords voting +voteReward: 100