Идея этого пет-проекта родилась из-за оттого, что я всегда был невнимательным. Я мог указать не ту дату в заявлении, мог забыть указать нужного получателя при отправке письма и вообще мне всегда лень возиться со всей этой бюрократией. И я решил автоматизировать отправку заявлений на отгул.


У каждого из нас, есть какая-то задумка, которую нам хотелось бы воплотить. Моей был телеграм-бот. Еще пару лет пять назад, когда я шерстил документацию телеги, я прочитал про ботов и решил, что его надо написать.


И сел думать.



Тогда я так ничего и не придумал. Но вот, наконец-то, свершилось.


Цели


С идеей определились, но из нее надо выжать все максимально полезное. Я недавно прочитал несколько хороших книг:


  1. Паттерны проектирования банды четырех
  2. Чистый Agile Мартина

Неплохо бы эти знания применить на практике.


Кроме того, я хотел подтянуть C#, потому что с тех пор, как я ушел на фронт, он продвинулся далеко вперед.


А я же по образованию инженер программист, а не инженер программист на JS. Поэтому выбираем языком разработки C#.


Поясню почему такое долгое вступление

Пет-проект это сложно и важно, так как, это инвестиция своего времени в свою стоимость как специалиста. Это то что можно показать сообществу и будущему работодателю при приеме на работу. Это возможность использовать максимально большое количество новых знаний так как хочешь ты, потому что ты работаешь один. Поэтому я так основательно подошел к постановке целей.

Предвижу ваш вопрос

Да, работать по agile в одного не то чтобы сильно весело. Но при этом никто не мешает покрывать все тестами, настроить CI/CD и делать маленькие порции задач так, чтобы всегда было стабильное состояние в develop.

Бизнес-постановка


Если коротко, то нужно чтобы я просто написал боту дату отгула, причину и буду ли я его отрабатывать в будущем.


Звучит просто, но для этого нам надо решить следующие вопросы:


  1. Формирование docx-документа (вообще, можно было бы обойтись обычным email, у нас в этом плане не жестят, но ведь так станет менее интересно)
  2. Формирование email-сообщения
  3. Отправка c помощью SMTP этого самого сообщения через яндекс с OAuth-токеном.

Спойлер: это все решаемо:)


Реализация


Формирование документов


Я решил не хитрить, и просто редактировать шаблоны. Можно было бы сделать генерацию документов, но зачем?
Во-первых, никакого профита нам это не даст ни в плане юзабельности, ни в плане редактирования контента.
Во-вторых, шаблоны можно будет менять даже во время того, когда приложение развернуто.


Docx


Шаблон документа


Вот так выглядит шаблон документа. Я сюда притащил ангулярщину в виде "усатых" плейсхолдеров. В общем, обычный документ без каких-либо изысков.


Нет, "Инфиннити" — не опечатка, у нас в названии две "н".

Для формирования документа я использую библиотеку "DocX". Она предоставляет удобный интерфейс для взаимодействия с документом.


Код адаптера документа
    public class TimeOffDocumentAdapter: IDisposable
    {
        protected DocX document;

        public TimeOffDocumentAdapter(DocX document)
        {
            this.document = document;
        }

        public void SetJobTitle(string value)
        {
            this.document.Paragraphs[2].ReplaceText(Placeholders.JobTitle, value);
        }

        public void SetPersonName(string value)
        {
            this.document.Paragraphs[3].ReplaceText(Placeholders.PersonName, value);
        }

        public void SetTimeOffPeriod(string value)
        {
            value = new InsertStringFormatter().Format(value);
            this.document.Paragraphs[6].ReplaceText(Placeholders.TimeOffPeriod, value);
        }

        public void SetReason(string value)
        {
            if (value == null)
            {
                this.document.Paragraphs[7].RemoveText(0);
                this.document.Paragraphs[7].Hide();
            }
            else
            {
                value = new InsertStringFormatter().Format(value);
                this.document.Paragraphs[7].ReplaceText(Placeholders.Reason, value);
            }
        }

        public void SetWorkingOff(string value)
        {
            if (value == null)
            {
                this.document.Paragraphs[8].RemoveText(0);
                this.document.Paragraphs[8].Hide();
            }
            else
            {
                value = new InsertStringFormatter().Format(value, firstLetter: FirstLetter.Upper);
                this.document.Paragraphs[8].ReplaceText(Placeholders.WorkingOff, value);
            }
        }

        public void SetSendingDay(string value)
        {
            this.document.Paragraphs[10].ReplaceText(Placeholders.SendingDay, value);
        }

        public void SaveAs(string name)
        {
            this.document.SaveAs(name);
        }

        public void Dispose()
        {
            document?.Dispose();
        }
    }

Никаких страшных хитростей нет — просто в определенных параграфах подменяем уникальные плейсхолдеры на нужные значения. Или же, если значение null, то просто скрываем параграф.


Email


А это — шаблон сообщения. То же самое, только HTML файлик для отображения в почтовом клиенте.


Шаблон сообщения


Логика примерно та же, но вместо "Docx" — "HTML Agility Pack", а вместо параграфов — HTML ноды.


Код адаптера сообщения
    public class TimeOffMessageAdapter
    {
        protected HtmlDocument Doc;

        public TimeOffMessageAdapter(HtmlDocument doc)
        {
            this.Doc = doc;
        }

        public void SetJobTitle(string value)
        {
            var jobTitle = Doc.GetElementbyId("signature_job-title");

            var signature = jobTitle.ParentNode;

            var replaced = jobTitle.OuterHtml.Replace(Placeholders.JobTitle, value);

            var newNode = HtmlNode.CreateNode(replaced);

            signature.ReplaceChild(newNode, jobTitle);
        }

        public void SetPersonName(string value)
        {

            var name = Doc.GetElementbyId("signature_name");

            var signature = name.ParentNode;

            var replaced = name.OuterHtml.Replace(Placeholders.PersonName, value);

            var newNode = HtmlNode.CreateNode(replaced);

            signature.ReplaceChild(newNode, name);
        }

        public void SetTimeOffPeriod(string value)
        {
            var period = Doc.GetElementbyId("time-off_period");

            var body = period.ParentNode;

            value = new InsertStringFormatter().Format(value);

            var replaced = period.OuterHtml.Replace(Placeholders.TimeOffPeriod, value);

            var newNode = HtmlNode.CreateNode(replaced);

            body.ReplaceChild(newNode, period);
        }

        public void SetReason(string value)
        {
            var reason = Doc.GetElementbyId("time-off_reason");

            var parent = reason.ParentNode;

            if (value == null)
            {
                parent.RemoveChild(reason);
            }
            else
            {
                value = new InsertStringFormatter().Format(value);

                var replaced = reason.OuterHtml.Replace(Placeholders.Reason, value);

                var newNode = HtmlNode.CreateNode(replaced);

                parent.ReplaceChild(newNode, reason);
            }
        }

        public void SetWorkingOff(string value)
        {
            var reason = Doc.GetElementbyId("time-off_working-off");

            var parent = reason.ParentNode;

            if (value == null)
            {
                parent.RemoveChild(reason);
            }
            else
            {
                value = new InsertStringFormatter().Format(value, firstLetter: FirstLetter.Upper);

                var replaced = reason.OuterHtml.Replace(Placeholders.WorkingOff, value);

                var newNode = HtmlNode.CreateNode(replaced);

                parent.ReplaceChild(newNode, reason);
            }
        }

        public void SaveAs(TextWriter writer)
        {
            this.Doc.Save(writer);
        }
    }

Реализация команд


Разве что ленивый не писал как делать телеграм-бота, так что я пропущу этап с тривиальными вещами и перейду к реализации команд.


Команда


Фактически, команда это алгоритмический объект, в который передается объект контекста и она выполняет свою работу.


Код команды
public abstract class Command
{
    protected readonly CancellationTokenSource CancellationToken;

    [JsonIgnore]
    public CommandContext Context { get; set; } = null!;

    protected long ChatId => Context.ChatId;

    protected string Message => Context.Message;

    protected Command()
    {
        CancellationToken = new CancellationTokenSource();
    }

    // Метод, который отрабатывает при отправке комманды
    public abstract Task Execute();

    /* Метод который вызывается, когда комманда болтается в сессии и ждет,
    что пользователь введет что-либо в ответ на запрос бота */
    public virtual Task<int> OnMessage()
    {
        return Task.FromResult(ExecuteDirection.RunNext);
    }

    // Метод, который проверяет, что то, что ввел в ответ пользователь — валидно
    public virtual Task ValidateMessage()
    {
        return Task.CompletedTask;
    }

    // Метод для перывания комманды
    public virtual Task Cancel()
    {
        this.CancellationToken.Cancel();

        return Task.CompletedTask;
    }

    // А об этом методе мы поговорим попозже:)
    protected void ForceComplete()
    {
        throw new ForceCompleteCommandException(this.GetType().Name);
    }
}

Пример команды Start
public class StartCommand: Command
{
    private readonly ILogger<StartCommand> _logger = LogPoint.GetLogger<StartCommand>();

    public const string Key = "/start";

    public override async Task Execute()
    {
        _logger.LogInformation($"{ChatId}: Started work");

        await this.Context.TelegramClient.SendMessage(
            ChatId, 
            "Добро пожаловать!\r\n" +
            "\r\n" +
            "Перед началом работы вам необходимо:\r\n" +
            "/registeruser – зарегистрироваться\r\n" +
            "/registermail – зарегистрировать рабочую почту");
    }
}

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


Для этого я сделал класс StatedCommand.


StatedCommand
public abstract class StatedCommand: Command
{
    private ILogger<StatedCommand> _logger = LogPoint.GetLogger<StatedCommand>();

    [JsonProperty]
    protected CommandClip Clip;

    protected StatedCommand()
    {
        var states = this.ConfigureStates();
        this.Clip = new CommandClip(states);
    }

    public abstract List<Command> ConfigureStates();

    public override async Task Execute()
    {
        try
        {
            await Clip.Run(Context);

            if (Clip.IsCompleted)
            {
                await OnComplete();
            }
            else
            {
                await Context.SaveSession(this);
            }
        }
        catch (ForceCompleteCommandException e)
        {
            await this.OnForceComplete(e);
        }
    }

    public override async Task<int> OnMessage()
    {
        await this.Execute();
        return ExecuteDirection.RunNext;
    }

    public override async Task Cancel()
    {
        await base.Cancel();

        await Clip.Cancel(Context);
    }

    protected async Task OnComplete()
    {
        await Context.SessionStorage.DeleteSession(ChatId);
    }

    private async Task OnForceComplete(ForceCompleteCommandException e)
    {
        await this.OnComplete();
        _logger.LogWarning($"Сommand {e.CommandName} force completed");
    }
}

Основной его задачей является просто взять команду из очереди (CommandClip) и выполнить ее, а когда выполнение прерывается, то подпереть за собой сессию в Redis.


Основные обязанности по выполнению потока команды на себя берет класс CommandClip.


CommandClip
public class CommandClip
{
    private readonly ILogger<CommandClip> _logger = LogPoint.GetLogger<CommandClip>();

    [JsonProperty]
    private readonly Command[] _states;

    [JsonProperty]
    private int _runIndex = 0;

    public bool IsFinishedChain => _runIndex == _states.Length;

    private int LastIndex => _states.Length - 1;

    public bool IsAsymmetricCompleted
    {
        get
        {
            if (_runIndex != LastIndex - 1)
            {
                return false;
            }
            else
            {
                return _states[_runIndex + 1].GetType() == typeof(AssymetricCompleteCommand);
            }
        }
    }

    public bool IsCompleted => IsFinishedChain || IsAsymmetricCompleted;

    public int RunIndex => _runIndex;

    public CommandClip(IEnumerable<Command> states)
    {
        _states = states.ToArray();
    }

    public async Task Run(CommandContext context)
    {
        if (IsFinishedChain) return;

        var firstPartCommand = _states[_runIndex];

        try
        {
            var firstPartExecutor = new CommandExecutor(firstPartCommand, context);

            await firstPartExecutor.ValidateMessage();
            var increment = await firstPartExecutor.OnMessage();

            if (increment <= 0)
            {
                context.BackwardRedirect = true;
            }

            this.IncrementStep(increment);

            if (IsFinishedChain) return;

            var secondPartCommand = _states[_runIndex];
            var secondPartExecutor = new CommandExecutor(secondPartCommand, context);

            _logger.LogInformation($"{context.ChatId}: Start execute command {secondPartCommand.GetType().Name}");
            await secondPartExecutor.Execute();
        }
        catch (IncorrectFormatException e)
        {
            _logger.LogWarning(e, $"Incorrect format of command {firstPartCommand.GetType().Name}: \"{context.Message}\"");
        }
    }

    public Task Cancel(CommandContext context)
    {
        var state = _states[_runIndex];
        var executor = new CommandExecutor(state, context);

        return executor.Cancel();
    }

    private void IncrementStep(int value)
    {
        this._runIndex += value;
    }
}

Каждая команда в методе OnMessage возвращает определенный код чтобы CommandClip знал, что ему вызывать дальше. Таким образом, если в команде CheckEmailsCommand пользователь ответит, что ввел емейлы не верно, то мы сможем заново вызвать команду SetEmailsCommand.


Этот вопрос, на самом деле, терзал меня довольно долго, стоит ли делать управление потоком именно таким образом, через возврат кода перехода. Ведь выходит так, что команда знает о том, какая она по счету, и кого можно дернуть следующим. Но потом я решил, что дочерние команды достаточно связанные сущности и пишутся под конкретный сценарий, так что они имеют на это право.

TimeOffCommand
public class TimeOffCommand: StatedCommand
{
    private ILogger<TimeOffCache> _logger = LogPoint.GetLogger<TimeOffCache>();

    public const string Key = "/timeoff";

    public override List<Command> ConfigureStates()
    {
        return new List<Command>()
        {
            new EmptyCommand(), 
            new EnterPeriodCommand(),
            new EnterReasonCommand(),
            new EnterWorkingOffCommand(),
            new CheckDocumentCommand(),
            new SetEmailsCommand(),
            new CheckEmailsCommand(),
            new SendDocumentCommand(),
            new AssymetricCompleteCommand(),
        };
    }

    public override async Task Execute()
    {
        try
        {
            await base.Execute();
        }
        catch (NonCompleteUserException e)
        {
            await HandleUserException(e);
            await OnComplete();

            this._logger.LogError(e, $"Command was completed by exception");
        }
    }

    private async Task HandleUserException(NonCompleteUserException e)
    {
        await new NonCompleteUserExceptionHandlerVisitor().Handle(e, ChatId, Context.TelegramClient);
    }
}

Первая проблема


Когда мы вызываем метод Run, он вызывает у той команды, что ждет ответа метод OnMessage, а затем у следующей метод Execute. И получается, когда мы пишем боту в ответ на запрос периода отгула дату, то метод EnterPeriodCommand.OnMessage сохраняет эту информацию, а следующий метод EnterReasonCommand.Execute пишет нам о том, что неплохо бы ввести еще и причину отгула.


Но тогда возникаем вопрос о том, что раз первым вызывается метод OnMessage, то в цепочке команд у каждой первой команды именно он всегда будет вызываться. Хотя, при этом пользователь ничего полезного нам не отправил, на что мы могли бы среагировать.


Для этого я ввел команду EmptyCommand, которая всегда стоит первой в цепочке и ничего не делает.


Вторая проблема


Финальная команда не ждет никакого ответа, вся её логика находится в методе Execute, потому что обязанность ровно в том, чтобы просто отправить письмо и никакого ответа пользователя она не ждет.


Для этого я ввел команду AssymetricCompleteCommand, если очередь видит, что это следующая команда, то очередь считает себя завершенной.


Использование всего этого дело выглядит как-то так:




А это на выходе:




Планы


Это долгоиграющий пет-проект, я планирую его развивать дальше. В нем есть огромный потенциал для роста: новые заявления, интеграция с календарем и т.д.


В нем есть простор для рефакторинга, потому что в паре мест я сделал не самые красивые решения.


В целом, я планирую развивать это решение и развиваться с его помощью:)


Итоги


Для меня было приятным опытом сделать то, что сделает мою жизнь и жизнь коллег проще. Приятно порешать такие головоломки, как гибкое выполнение очереди команд. Наверное это один из самых увлекательным моих пет-проектов, потому что к его проработке я подошел особенно тщательно.


Так вышло, что здесь было где развернуться и паттернам и разработке архитектуры. Я поигрался и с CI/CD, и c Redis. Покрыл все тестами и поработал с гибкими методологиями.


Весь проект разобрать не получилось, так как он достаточно большой для формата одной статьи, но вот ссылочки на потенциально интересные места:



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


Если вдруг есть интересные замечания, или найдете недочеты, то пишите мне, я всегда за то, чтобы выслушать хороший совет:)

Комментарии (8)


  1. navferty
    07.09.2022 00:50
    +2

    Вместо простой замены текста, можно попробовать сделать Merge fields, примерно так:
    C# Mail Merge API | MS Word Mail Merge API for .NET (не уверен что выбранная Вами библиотека это поддерживает, но вообще функция mail merge в Word'е довольно полезная, хотя и не очень широко известна)


    1. Hrodvitnir Автор
      07.09.2022 05:15

      Спасибо, тоже посмотрю)


  1. webalex127
    07.09.2022 04:38
    +3

    Как проверяется права пользователя? Если бот улетит в паблик случайно, то есть вероятность засрамить чью то почту


    1. Hrodvitnir Автор
      07.09.2022 04:41

      В плане защиты от спамящих хулиганов никакой защиты нет. Но спасибо, я сделаю whitelist доменов для отправки)


  1. Krawler
    07.09.2022 04:42
    +3

    У Ворда есть такая штука как поля. Поиск текста по плейсхолдерам это очень и очень топорное решение. Гораздо гибче еслр вы создадите 2 кастомных поля в документе (причина, отработка, ещё можно добавить название компании, ФИО и должность руководителя) в коде вы просто заполняете поля и вызываете UpdateFields(). Все. Код сильно сократится в таком случае


    1. Hrodvitnir Автор
      07.09.2022 04:45

      Спасибо, посмотрю это дело. О полях я совсем не подумал)

      Но в свою защиту могу сказать, что плейсхолдер мы ищем не по всему документу, а только по определённому абзазу)
      Но это тоже топорненько, вы правы)


  1. Podkrepushka
    08.09.2022 01:57
    +1

    Для Word есть прекрасный шаблонизатор:https://github.com/UNIT6-open/TemplateEngine.Docx
    Для своего пет проекта, который так же предусматривает работу с телеграмм ботом выбрал BotFramework от Microsoft, возможности его расширения почти ничем не ограничены, для офлайн работы этой библиотеки следует реализовать telegram-адпатер, не очень большая задача для получения почти не ограниченного функционала управления диалогами в чате.
    Может что-то возьмешь на вооружение.


    1. Hrodvitnir Автор
      08.09.2022 06:23

      Спасибо