Como fiz um Discord Bot para uma Game Guild com .NET Core

Introdução

Olá a todos! Recentemente, escrevi um bot Discord para a guilda de World of Warcraft. Ele regularmente coleta dados sobre os jogadores dos servidores do jogo e escreve mensagens no Discord informando que um novo jogador se juntou à guilda ou que um antigo jogador deixou a guilda. Entre nós, apelidamos este bot de Batrak.





Neste artigo, decidi compartilhar minha experiência e dizer como fazer esse projeto. Em essência, vamos implementar um microsserviço no .NET Core: vamos escrever a lógica, integrar com a API de serviços de terceiros, cobri-la com testes, empacotá-la no Docker e colocá-la no Heroku. Além disso, vou mostrar como implementar a integração contínua usando o Github Actions.





Nenhum conhecimento do jogo é exigido de você . Escrevi o material para que fosse possível abstrair do jogo e fiz um esboço dos dados sobre os jogadores. Mas se você tiver uma conta Battle.net, poderá obter dados reais.





Para entender o material, espera-se que você tenha pelo menos uma experiência mínima na criação de serviços da Web usando a estrutura ASP.NET e um pouco de experiência com o Docker.





Plano

A cada etapa, aumentaremos gradativamente a funcionalidade.





  1. web api /check. “Hello!” Discord .





  2. .





  3. . Discord.





  4. Dockerfile Heroku.





  5. .





  6. , master





1. Discord

ASP.NET Core Web API .





- . Github . Github.









[ApiController]
public class GuildController : ControllerBase
{
    [HttpGet("/check")]
    public async Task<IActionResult> Check(CancellationToken ct)
    {
        return Ok();
    }
}
      
      



webhook Discord . Webhook - . , http .





integrations Discord .





Criação de um webhook
webhook

webhook appsettings.json . Heroku. ASP Core .





{
	"DiscordWebhook":"https://discord.com/api/webhooks/****/***"
}
      
      



DiscordBroker, Discord. Services , .





post webhook .





public class DiscordBroker : IDiscordBroker
{
    private readonly string _webhook;
    private readonly HttpClient _client;

    public DiscordBroker(IHttpClientFactory clientFactory, IConfiguration configuration)
    {
        _client = clientFactory.CreateClient();
        _webhook = configuration["DiscordWebhook"];
    }

    public async Task SendMessage(string message, CancellationToken ct)
    {
        var request = new HttpRequestMessage
        {
            Method = HttpMethod.Post,
            RequestUri = new Uri(_webhook),
            Content = new FormUrlEncodedContent(new[] {new KeyValuePair<string, string>("content", message)})
        };

        await _client.SendAsync(request, ct);
    }
}
      
      



, . IConfiguration webhook , IHttpClientFactory HttpClient.





, , . .





Startup.





services.AddScoped<IDiscordBroker, DiscordBroker>();
      
      



HttpClient, IHttpClientFactory.





services.AddHttpClient();
      
      



.





private readonly IDiscordBroker _discordBroker;

public GuildController(IDiscordBroker discordBroker)
{
  _discordBroker = discordBroker;
}

[HttpGet("/check")]
public async Task<IActionResult> Check(CancellationToken ct)
{
  await _discordBroker.SendMessage("Hello", ct);
  return Ok();
}
      
      



, /check Discord .





2. Battle.net

: battle.net . battle.net, .









https://develop.battle.net/ BattleNetId BattleNetSecret. api . appsettings.





ArgentPonyWarcraftClient.





BattleNetApiClient Services.





public class BattleNetApiClient
{
   private readonly string _guildName;
   private readonly string _realmName;
   private readonly IWarcraftClient _warcraftClient;

   public BattleNetApiClient(IHttpClientFactory clientFactory, IConfiguration configuration)
   {
       _warcraftClient = new WarcraftClient(
           configuration["BattleNetId"],
           configuration["BattleNetSecret"],
           Region.Europe,
           Locale.ru_RU,
           clientFactory.CreateClient()
       );
       _realmName = configuration["RealmName"];
       _guildName = configuration["GuildName"];
   }
}
      
      



WarcraftClient.

, . .





, appsettings RealmName GuildName. RealmName , GuildName . .





GetGuildMembers WowCharacterToken .





public async Task<WowCharacterToken[]> GetGuildMembers()
{
   var roster = await _warcraftClient.GetGuildRosterAsync(_realmName, _guildName, "profile-eu");

   if (!roster.Success) throw new ApplicationException("get roster failed");

   return roster.Value.Members.Select(x => new WowCharacterToken
   {
       WowId = x.Character.Id,
       Name = x.Character.Name
   }).ToArray();
}
      
      



public class WowCharacterToken
{
  public int WowId { get; set; }
  public string Name { get; set; }
}
      
      



WowCharacterToken Models.





BattleNetApiClient Startup.





services.AddScoped<IBattleNetApiClient, BattleNetApiClient>();
      
      







WowCharacterToken Models. .





public class WowCharacterToken
{
  public int WowId { get; set; }
  public string Name { get; set; }
}
      
      







public class BattleNetApiClient
{
    private bool _firstTime = true;

    public Task<WowCharacterToken[]> GetGuildMembers()
    {
        if (_firstTime)
        {
            _firstTime = false;

            return Task.FromResult(new[]
            {
                new WowCharacterToken
                {
                    WowId = 1,
                    Name = ""
                },
                new WowCharacterToken
                {
                    WowId = 2,
                    Name = ""
                }
            });
        }

        return Task.FromResult(new[]
        {
            new WowCharacterToken
            {
                WowId = 1,
                Name = ""
            },
            new WowCharacterToken
            {
                WowId = 3,
                Name = ""
            }
        });
    }
}
      
      



. , . api. .





Startup.





services.AddScoped<IBattleNetApiClient, BattleNetApiClient>();
      
      



Discord





BattleNetApiClient, - Discord.





[ApiController]
public class GuildController : ControllerBase
{
  private readonly IDiscordBroker _discordBroker;
  private readonly IBattleNetApiClient _battleNetApiClient;

  public GuildController(IDiscordBroker discordBroker, IBattleNetApiClient battleNetApiClient)
  {
     _discordBroker = discordBroker;
     _battleNetApiClient = battleNetApiClient;
  }

  [HttpGet("/check")]
  public async Task<IActionResult> Check(CancellationToken ct)
  {
     var members = await _battleNetApiClient.GetGuildMembers();
     await _discordBroker.SendMessage($"Members count: {members.Length}", ct);
     return Ok();
  }
}
      
      



3.

api. InMemory ( ) .





InMemory , . Redis Heroku .





InMemory Startup.





services.AddMemoryCache(); 
      
      



IDistributedCache, . , . GuildRepository Repositories.





public class GuildRepository : IGuildRepository
{
    private readonly IDistributedCache _cache;
    private const string Key = "wowcharacters";

    public GuildRepository(IDistributedCache cache)
    {
        _cache = cache;
    }

    public async Task<WowCharacterToken[]> GetCharacters(CancellationToken ct)
    {
        var value = await _cache.GetAsync(Key, ct);

        if (value == null) return Array.Empty<WowCharacterToken>();

        return await Deserialize(value);
    }

    public async Task SaveCharacters(WowCharacterToken[] characters, CancellationToken ct)
    {
        var value = await Serialize(characters);

        await _cache.SetAsync(Key, value, ct);
    }
    
    private static async Task<byte[]> Serialize(WowCharacterToken[] tokens)
    {
        var binaryFormatter = new BinaryFormatter();
        await using var memoryStream = new MemoryStream();
        binaryFormatter.Serialize(memoryStream, tokens);
        return memoryStream.ToArray();
    }

    private static async Task<WowCharacterToken[]> Deserialize(byte[] bytes)
    {
        await using var memoryStream = new MemoryStream();
        var binaryFormatter = new BinaryFormatter();
        memoryStream.Write(bytes, 0, bytes.Length);
        memoryStream.Seek(0, SeekOrigin.Begin);
        return (WowCharacterToken[]) binaryFormatter.Deserialize(memoryStream);
    }
}
      
      



GuildRepository Singletone , .





services.AddSingleton<IGuildRepository, GuildRepository>();
      
      



.





public class GuildService
{
    private readonly IBattleNetApiClient _battleNetApiClient;
    private readonly IGuildRepository _repository;
    public GuildService(IBattleNetApiClient battleNetApiClient, IGuildRepository repository)
    {
        _battleNetApiClient = battleNetApiClient;
        _repository = repository;
    }
    public async Task<Report> Check(CancellationToken ct)
    {
        var newCharacters = await _battleNetApiClient.GetGuildMembers();
        var savedCharacters = await _repository.GetCharacters(ct);
        await _repository.SaveCharacters(newCharacters, ct);
        if (!savedCharacters.Any())
            return new Report
            {
                JoinedMembers = Array.Empty<WowCharacterToken>(),
                DepartedMembers = Array.Empty<WowCharacterToken>(),
                TotalCount = newCharacters.Length
            };
        var joined = newCharacters.Where(x => savedCharacters.All(y => y.WowId != x.WowId)).ToArray();
        var departed = savedCharacters.Where(x => newCharacters.All(y => y.Name != x.Name)).ToArray();
        return new Report
        {
            JoinedMembers = joined,
            DepartedMembers = departed,
            TotalCount = newCharacters.Length
        };
    }
}
      
      



Report. Models.





public class Report
{
   public WowCharacterToken[] JoinedMembers { get; set; }
   public WowCharacterToken[] DepartedMembers { get; set; }
   public int TotalCount { get; set; }
}
      
      



GuildService .





[HttpGet("/check")]
public async Task<IActionResult> Check(CancellationToken ct)
{
   var report = await _guildService.Check(ct);

   return new JsonResult(report, new JsonSerializerOptions
   {
      Encoder = JavaScriptEncoder.Create(UnicodeRanges.BasicLatin, UnicodeRanges.Cyrillic)
   });
}
      
      



Discord .





if (joined.Any() || departed.Any())
{
   foreach (var c in joined)
      await _discordBroker.SendMessage(
         $":smile: **{c.Name}**   ",
         ct);
   foreach (var c in departed)
      await _discordBroker.SendMessage(
         $":smile: **{c.Name}**  ",
         ct);
}
      
      



GuildService Check. , . Discord GuildService.





. ArgentPonyWarcraftClient





await _warcraftClient.GetCharacterProfileSummaryAsync(_realmName, name.ToLower(), Namespace);
      
      



BattleNetApiClient, .





Unit





GuildService , . . BattleNetApiClient, GuildRepository DiscordBroker. .





Unit . Fakes .





public class DiscordBrokerFake : IDiscordBroker
{
   public List<string> SentMessages { get; } = new();
   public Task SendMessage(string message, CancellationToken ct)
   {
      SentMessages.Add(message);
      return Task.CompletedTask;
   }
}
      
      



public class GuildRepositoryFake : IGuildRepository
{
    public List<WowCharacterToken> Characters { get; } = new();

    public Task<WowCharacterToken[]> GetCharacters(CancellationToken ct)
    {
        return Task.FromResult(Characters.ToArray());
    }

    public Task SaveCharacters(WowCharacterToken[] characters, CancellationToken ct)
    {
        Characters.Clear();
        Characters.AddRange(characters);
        return Task.CompletedTask;
    }
}
      
      



public class BattleNetApiClientFake : IBattleNetApiClient
{
   public List<WowCharacterToken> GuildMembers { get; } = new();
   public List<WowCharacter> Characters { get; } = new();
   public Task<WowCharacterToken[]> GetGuildMembers()
   {
      return Task.FromResult(GuildMembers.ToArray());
   }
}
      
      



. Moq. .





GuildService :





[Test]
public async Task SaveNewMembers_WhenCacheIsEmpty()
{
   var wowCharacterToken = new WowCharacterToken
   {
      WowId = 100,
      Name = "Sam"
   };
   
   var battleNetApiClient = new BattleNetApiApiClientFake();
   battleNetApiClient.GuildMembers.Add(wowCharacterToken);

   var guildRepositoryFake = new GuildRepositoryFake();

   var guildService = new GuildService(battleNetApiClient, null, guildRepositoryFake);

   var changes = await guildService.Check(CancellationToken.None);

   changes.JoinedMembers.Length.Should().Be(0);
   changes.DepartedMembers.Length.Should().Be(0);
   changes.TotalCount.Should().Be(1);
   guildRepositoryFake.Characters.Should().BeEquivalentTo(wowCharacterToken);

}
      
      



, , . , Should, Be... FluentAssertions, Assertion .





. , .





. .





4. Docker Heroku!

Heroku. Heroku .NET , Docker .





Docker Dockerfile





FROM mcr.microsoft.com/dotnet/sdk:5.0 AS builder
WORKDIR /sources
COPY *.sln .
COPY ./src/peon.csproj ./src/
COPY ./tests/tests.csproj ./tests/
RUN dotnet restore
COPY . .
RUN dotnet publish --output /app/ --configuration Release
FROM mcr.microsoft.com/dotnet/core/aspnet:3.1
WORKDIR /app
COPY --from=builder /app .
CMD ["dotnet", "peon.dll"]
      
      



peon.dll Solution. Peon .





Docker Heroku . .





Heroku, Heroku CLI.





heroku .





heroku git:remote -a project_name
      
      



heroku.yml . :





build:
  docker:
    web: Dockerfile
      
      



:





#   heroku registry
heroku container:login

#      registry
heroku container:push web

#    
heroku container:release web
      
      



:





heroku open
      
      



Heroku, Redis . InMemory .





Heroku RedisCloud.





Redis REDISCLOUD_URL. , Heroku.





.





Microsoft.Extensions.Caching.StackExchangeRedis.





Redis IDistributedCache Startup.





services.AddStackExchangeRedisCache(o =>
{
   o.InstanceName = "PeonCache";
   var redisCloudUrl = Environment.GetEnvironmentVariable("REDISCLOUD_URL");
   if (string.IsNullOrEmpty(redisCloudUrl))
   {
      throw new ApplicationException("redis connection string was not found");
   }
   var (endpoint, password) = RedisUtils.ParseConnectionString(redisCloudUrl);
   o.ConfigurationOptions = new ConfigurationOptions
   {
      EndPoints = {endpoint},
      Password = password
   };
});
      
      



REDISCLOUD_URL . RedisUtils. :





public static class RedisUtils
{
   public static (string endpoint, string password) ParseConnectionString(string connectionString)
   {
      var bodyPart = connectionString.Split("://")[1];
      var authPart = bodyPart.Split("@")[0];
      var password = authPart.Split(":")[1];
      var endpoint = bodyPart.Split("@")[1];
      return (endpoint, password);
   }
}
      
      



Unit .





[Test]
public void ParseConnectionString()
{
   const string example = "redis://user:password@url:port";
   var (endpoint, password) = RedisUtils.ParseConnectionString(example);
   endpoint.Should().Be("url:port");
   password.Should().Be("password");
}
      
      



, GuildRepository , Redis. .





.





5.

, 15 .





:





- https://cron-job.org. get /check N .





- Hosted Services. ASP.NET Core . , Heroku . Hosted Service . . , .





- Cron . Heroku Scheduler. cron job Heroku.





6. ,

-, Heroku.





Deploy. Github Automatic deploys master.





Wait for CI to pass before deploy. Heroku . , .





Github Actions.





Actions. workflow .NET





dotnet.yml. .





, build master.





on:
  push:
    branches: [ master ]
  pull_request:
    branches: [ master ]
      
      



. , dotnet build dotnet test.





    steps:
    - uses: actions/checkout@v2
    - name: Setup .NET
      uses: actions/setup-dotnet@v1
      with:
        dotnet-version: 5.0.x
    - name: Restore dependencies
      run: dotnet restore
    - name: Build
      run: dotnet build --no-restore
    - name: Test
      run: dotnet test --no-build --verbosity normal
      
      



Você não precisa alterar nada neste arquivo, tudo já funcionará fora da caixa.





Empurre algo no mestre e veja se o trabalho começa. A propósito, ele já deveria ter começado após a criação de um novo fluxo de trabalho.





Excelente! Então, fizemos um microsserviço no .NET Core que é coletado e publicado no Heroku. O projeto tem muitos pontos para desenvolvimento: pode adicionar registro, testes de bomba, métricas de travamento, etc. etc.





Espero que este artigo tenha lhe dado algumas novas idéias e tópicos para explorar. Obrigado pela atenção. Boa sorte com seus projetos!








All Articles