Cómo creé un bot de Discord para un gremio de juegos con .NET Core

Introducción

¡Hola a todos! Recientemente escribí un bot de Discord para el gremio de World of Warcraft. Regularmente recopila datos sobre los jugadores de los servidores del juego y escribe mensajes en Discord que un nuevo jugador se ha unido al gremio o que un jugador antiguo ha dejado el gremio. Entre nosotros, apodamos a este bot Batrak.





En este artículo, decidí compartir mi experiencia y contarte cómo hacer un proyecto de este tipo. En esencia, implementaremos un microservicio en .NET Core: escribiremos la lógica, lo integraremos con la api de servicios de terceros, lo cubriremos con pruebas, lo empaquetaremos en Docker y lo colocaremos en Heroku. Además, te mostraré cómo implementar la integración continua usando Github Actions.





No se requiere ningún conocimiento del juego . Escribí el material para que fuera posible abstraerse del juego e hice un talón de los datos sobre los jugadores. Pero si tienes una cuenta de Battle.net, puedes obtener datos reales.





Para comprender el material, se espera que tenga al menos una experiencia mínima en la creación de servicios web utilizando el marco ASP.NET y un poco de experiencia con Docker.





Plan

En cada paso, aumentaremos gradualmente la funcionalidad.





  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 .





Creando un 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
      
      



No necesita cambiar nada en este archivo, todo ya funcionará de inmediato.





Empuje algo en el maestro y vea si comienza el trabajo. Por cierto, ya debería haber comenzado después de crear un nuevo flujo de trabajo.





¡Excelente! Así que hicimos un microservicio en .NET Core que se recopila y publica en Heroku. El proyecto tiene muchos puntos para el desarrollo: podría agregar registros, pruebas de bombeo, métricas de suspensión, etc. etc.





Con suerte, este artículo le ha brindado un par de nuevas ideas y temas para explorar. Gracias por la atención. Buena suerte con tus proyectos!








All Articles