Как я сделал Discord бота для игровой гильдии с помощью .NET Core

Вступление

Всем привет! Недавно я написал Discord бота для World of Warcraft гильдии. Он регулярно забирает данные об игроках с серверов игры и пишет сообщения в Discord о том что к гильдии присоединился новый игрок или о том что гильдию покинул старый игрок. Между собой мы прозвали этого бота Батрак.

В этой статье я решил поделиться опытом и рассказать как сделать такой проект. По сути мы будем реализовывать микросервис на .NET Core: напишем логику, проведем интеграцию с api сторонних сервисов, покроем тестами, упакуем в Docker и разместим в Heroku. Кроме этого я покажу как реализовать continuous integration с помощью Github Actions.

От вас не потребуется никаких знаний об игре. Я написал материал так чтобы можно было абстрагироваться от игры и сделал заглушку для данных об игроках. Но если у вас есть учетная запись в Battle.net, то вы сможете получать реальные данные.

Для понимания материала, от вас ожидается хотя бы минимальный опыт создания веб сервисов с помощью фреймворка ASP.NET и небольшой опыт работы с Docker.

План

На каждом шаге будем постепенно наращивать функционал.

  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 Check(CancellationToken ct)
    {
        return Ok();
    }
}

Затем нам понадобится webhook от вашего Discord сервера. Webhook — это механизм отправки событий. В данном случае, то это адрес к которому можно слать простые http запросы с сообщениями внутри.

Получить его можно в пункте integrations в настройках любого текстового канала вашего Discord сервера.

Создание 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("content", message)})
        };

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

Как видите, мы используем внедрение зависимостей. IConfiguration позволит нам достать webhook из конфигов, а IHttpClientFactory создать новый HttpClient.

Кроме того, я извлек интерфейс этого класса, чтобы в дальнейшем можно было сделать его заглушку при тестировании. Делайте это для всех сервисов которые мы будем создавать далее.

Не забудьте что новый класс нужно будет зарегистрировать в Startup.

services.AddScoped();

А также нужно будет зарегистрировать HttpClient, для работы IHttpClientFactory.

services.AddHttpClient();

Теперь можно воспользоваться новым классом в контроллере.

private readonly IDiscordBroker _discordBroker;

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

[HttpGet("/check")]
public async Task 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 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();

Берем данные из заглушки

Для начала создадим модель WowCharacterToken и поместим ее в папку Models. Она представляет собой информацию об игроке.

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

Дальше сделаем вот такой класс

public class BattleNetApiClient
{
    private bool _firstTime = true;

    public Task 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();

Выведем результаты в 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 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 GetCharacters(CancellationToken ct)
    {
        var value = await _cache.GetAsync(Key, ct);

        if (value == null) return Array.Empty();

        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 Serialize(WowCharacterToken[] tokens)
    {
        var binaryFormatter = new BinaryFormatter();
        await using var memoryStream = new MemoryStream();
        binaryFormatter.Serialize(memoryStream, tokens);
        return memoryStream.ToArray();
    }

    private static async Task 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();

Теперь можно написать сервис который будет сравнивать новый список игроков с сохраненным.

public class GuildService
{
    private readonly IBattleNetApiClient _battleNetApiClient;
    private readonly IGuildRepository _repository;
    public GuildService(IBattleNetApiClient battleNetApiClient, IGuildRepository repository)
    {
        _battleNetApiClient = battleNetApiClient;
        _repository = repository;
    }
    public async Task 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(),
                DepartedMembers = Array.Empty(),
                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 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 SentMessages { get; } = new();
   public Task SendMessage(string message, CancellationToken ct)
   {
      SentMessages.Add(message);
      return Task.CompletedTask;
   }
}
public class GuildRepositoryFake : IGuildRepository
{
    public List Characters { get; } = new();

    public Task 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 GuildMembers { get; } = new();
   public List Characters { get; } = new();
   public Task 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.

b3bed127b599be1bbc10a13e758c818e.png

Поставьте галочку у пункта Wait for CI to pass before deploy. Нам нужно чтобы Heroku дожидался сборки и прогонки тестов. Если тесты покраснеют, то публикация не случится.

Сделаем сборку и прогонку тестов в Github Actions.

Зайдите в репозиторий и перейдите в пункт Actions. Теперь создайте новый workflow на основе шаблона .NET

c0c2504e90f75c46d3c59d8bdc62cc6e.png

В репозитории появится новый файл 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

Менять в этом файле ничего не нужно, все уже будет работать из коробки.

Запушьте что-нибудь в master и посмотрите что задание запускается. Кстати, оно уже должно было запуститься после создания нового workflow.

7f91b89075291f3cff1bb712157ef14e.png

Отлично! Вот мы и сделали микросервис на .NET Core который собирается и публикуется в Heroku. У проекта есть множество точек для развития: можно было бы добавить логирование, прокачать тесты, повесить метрики и. т. д.

Надеюсь данная статья подкинула вам пару новых идей и тем для изучения. Спасибо за внимание. Удачи вам в ваших проектах!

d5f95a7bccbce5db0b7e1cca321d4dba.png

© Habrahabr.ru