.NET Core を使用してゲーム ギルドの Discord ボットを作成した方法

前書き

みなさん、こんにちは!最近、World of Warcraft ギルドの Discord ボットを作成しました。彼は定期的にゲーム サーバーからプレーヤーに関するデータを収集し、新しいプレーヤーがギルドに参加したこと、または古いプレーヤーがギルドを離れたというメッセージを Discord に書き込みます。私たちの間では、このボットに Batrak というニックネームを付けました。





この記事では、私の経験を共有し、そのようなプロジェクトを作成する方法を説明することにしました。基本的に、.NET Core にマイクロサービスを実装します。ロジックを記述し、サードパーティ サービスの API と統合し、テストでカバーし、Docker にパッケージ化して Heroku に配置します。また、Github Actionsを利用した継続的インテグレーションの実装方法も紹介します。





ゲームの知識は必要ありませんゲームから抽象化できるように素材を書き、プレイヤーに関するデータのスタブを作成しました。しかし、Battle.net アカウントを持っている場合は、実際のデータを取得できます。





資料を理解するには、ASP.NET フレームワークを使用して Web サービスを作成するための最小限の経験と、Docker の少しの経験が必要です。





予定

各ステップで、徐々に機能を増やします。





  1. 1 つのコントローラー/チェックで新しい Web api プロジェクトを作成しましょう。このアドレスにアクセスすると、「Hello!」という文字列が送信されます。Discordチャットで。





  2. 既製のライブラリまたはスタブを使用して、ギルドの構成に関するデータを取得する方法を学びます。





  3. 次のチェック中にリストの以前のバージョンとの違いを見つけることができるように、プレーヤーの結果リストをキャッシュに保存する方法を学びましょう。Discordのすべての変更について書きます。





  4. プロジェクトの Dockerfile を作成し、Heroku ホスティングでプロジェクトをホストしましょう。





  5. 定期的なコード実行を行ういくつかの方法を見てみましょう。





  6. マスターにコミットするたびに、自動ビルド、テストの実行、プロジェクトの公開を実装します





ステップ 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 .





ウェブフックの作成
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
      
      



このファイル内の何も変更する必要はありません。すべてが箱から出してすぐに動作します。





何かをマスターにプッシュして、ジョブが開始するかどうかを確認します。ちなみに、新しいワークフローを作成した後、すでに開始されているはずです。





優れた!そこで、収集して Heroku で公開する .NET Core でマイクロサービスを作成しました。プロジェクトには開発のための多くのポイントがあります: ログ、ポンプ テスト、ハング メトリックなどを追加できます。





この記事が、探求すべきいくつかの新しいアイデアとトピックを提供してくれることを願っています。ご注目ありがとうございます。あなたのプロジェクトで頑張ってください!








All Articles