Идея этого пет-проекта родилась из-за оттого, что я всегда был невнимательным. Я мог указать не ту дату в заявлении, мог забыть указать нужного получателя при отправке письма и вообще мне всегда лень возиться со всей этой бюрократией. И я решил автоматизировать отправку заявлений на отгул.
У каждого из нас, есть какая-то задумка, которую нам хотелось бы воплотить. Моей был телеграм-бот. Еще пару лет пять назад, когда я шерстил документацию телеги, я прочитал про ботов и решил, что его надо написать.
И сел думать.
Тогда я так ничего и не придумал. Но вот, наконец-то, свершилось.
Цели
С идеей определились, но из нее надо выжать все максимально полезное. Я недавно прочитал несколько хороших книг:
- Паттерны проектирования банды четырех
- Чистый Agile Мартина
Неплохо бы эти знания применить на практике.
Кроме того, я хотел подтянуть C#, потому что с тех пор, как я ушел на фронт, он продвинулся далеко вперед.
А я же по образованию инженер программист, а не инженер программист на JS. Поэтому выбираем языком разработки C#.
Поясню почему такое долгое вступление
Пет-проект это сложно и важно, так как, это инвестиция своего времени в свою стоимость как специалиста. Это то что можно показать сообществу и будущему работодателю при приеме на работу. Это возможность использовать максимально большое количество новых знаний так как хочешь ты, потому что ты работаешь один. Поэтому я так основательно подошел к постановке целей.
Предвижу ваш вопрос
Да, работать по agile в одного не то чтобы сильно весело. Но при этом никто не мешает покрывать все тестами, настроить CI/CD и делать маленькие порции задач так, чтобы всегда было стабильное состояние в develop.
Бизнес-постановка
Если коротко, то нужно чтобы я просто написал боту дату отгула, причину и буду ли я его отрабатывать в будущем.
Звучит просто, но для этого нам надо решить следующие вопросы:
- Формирование docx-документа (вообще, можно было бы обойтись обычным email, у нас в этом плане не жестят, но ведь так станет менее интересно)
- Формирование email-сообщения
- Отправка 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
, то просто скрываем параграф.
А это — шаблон сообщения. То же самое, только 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);
}
}
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
.
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
.
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
.
Этот вопрос, на самом деле, терзал меня довольно долго, стоит ли делать управление потоком именно таким образом, через возврат кода перехода. Ведь выходит так, что команда знает о том, какая она по счету, и кого можно дернуть следующим. Но потом я решил, что дочерние команды достаточно связанные сущности и пишутся под конкретный сценарий, так что они имеют на это право.
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)
webalex127
07.09.2022 04:38+3Как проверяется права пользователя? Если бот улетит в паблик случайно, то есть вероятность засрамить чью то почту
Hrodvitnir Автор
07.09.2022 04:41В плане защиты от спамящих хулиганов никакой защиты нет. Но спасибо, я сделаю whitelist доменов для отправки)
Krawler
07.09.2022 04:42+3У Ворда есть такая штука как поля. Поиск текста по плейсхолдерам это очень и очень топорное решение. Гораздо гибче еслр вы создадите 2 кастомных поля в документе (причина, отработка, ещё можно добавить название компании, ФИО и должность руководителя) в коде вы просто заполняете поля и вызываете UpdateFields(). Все. Код сильно сократится в таком случае
Hrodvitnir Автор
07.09.2022 04:45Спасибо, посмотрю это дело. О полях я совсем не подумал)
Но в свою защиту могу сказать, что плейсхолдер мы ищем не по всему документу, а только по определённому абзазу)
Но это тоже топорненько, вы правы)
Podkrepushka
08.09.2022 01:57+1Для Word есть прекрасный шаблонизатор:https://github.com/UNIT6-open/TemplateEngine.Docx
Для своего пет проекта, который так же предусматривает работу с телеграмм ботом выбрал BotFramework от Microsoft, возможности его расширения почти ничем не ограничены, для офлайн работы этой библиотеки следует реализовать telegram-адпатер, не очень большая задача для получения почти не ограниченного функционала управления диалогами в чате.
Может что-то возьмешь на вооружение.
navferty
Вместо простой замены текста, можно попробовать сделать Merge fields, примерно так:
C# Mail Merge API | MS Word Mail Merge API for .NET (не уверен что выбранная Вами библиотека это поддерживает, но вообще функция mail merge в Word'е довольно полезная, хотя и не очень широко известна)
Hrodvitnir Автор
Спасибо, тоже посмотрю)