Checking enrollment in a university via a telegram bot

Opening greeting

Opening greeting

What do we need for this? A couple of free hours to deploy a simple .NET application and hosting where it can be launched. As an application, I will create an ASP.NET application, as a hosting I decided not to bother with a VPS, but to push directly from the repository to Amvera Cloud.

This is how a small project was born, which could be contained in one Program.cs file. We know the page where the enrollment information is published. Unfortunately, it comes to the site from the back as a ready-made HTML page, so you will have to download it and look for the necessary information. In general, the table for each training program looks like this.

Apart from the total number of columns, it is pleasing that the table is the same for all areas of education.

Apart from the total number of columns, it is pleasing that the table is the same for all areas of education.

Accordingly, we need to visit from time to time. HttpClient'om to the page, look for the necessary line in the table with our number and check the last column. The logic is simple and crude, but the main thing is that it works. I had futile attempts to prevent loading, somehow understanding in advance whether the information on the page had changed. I tried to use ETag And If-None-Match, Last-Modified And If-Modified-Sincebut with each request 304 was never returned. Therefore, through HtmlAgilityPack I started to simply search for the required line by number and check the last value.

using HttpClient client = new HttpClient();
        string pageContent = await client.GetStringAsync(url);

        HtmlDocument document = new HtmlDocument();
        document.LoadHtml(pageContent);

        string xpath = $"//tr[td[contains(text(), '{targetNumber}')]]";

        HtmlNode? targetRow = document.DocumentNode.SelectSingleNode(xpath);

        if (targetRow != null)
        {
            HtmlNode? statusNode = targetRow.SelectSingleNode("td[last()]");

            if (statusNode != null)
            {
                string status = statusNode.InnerText.Trim();
                Console.WriteLine($"Статус для номера {targetNumber}: {status}");
            }
            else
            {
                Console.WriteLine($"Не удалось найти статус для номера {targetNumber}");
            }
        }
        else
        {
            Console.WriteLine($"Не удалось найти строку для номера {targetNumber}");
        }

It would seem that this is where it could have ended, just go through the cron and check for status changes, and send status information to TG. But by the time I made the bot, I had figured out two unpleasant things. First, I was looking for information in the wrong place. It turns out that there are competitive lists (where I was initially looking), where there is information on enrollment, and there are separate lists of enrolled. They look the same, the essence is the same, only in the second version there is no information on vacant places, only information on enrolled, and it is updated more often. And secondly, when I realized this, I found out that I was enrolled, and the need for the bot disappeared by itself.

However, I realized that I hadn't written code outside of work tasks for a long time, and the idea seemed interesting – to make a bot that would store information about all available university programs, store information on enrollment within the admissions campaign and provide the ability to check the enrollment status, as well as subscribe to status changes. And although this is no longer relevant this year, and probably 90% of all applicants have already been enrolled (by the time this article was written, September had already arrived and the first organizational meetings had taken place), but in a year it might be relevant for someone. That's the plan.

  1. Collect links to all lists of enrolled students from the university website.

  2. Each such list should be sorted and saved in the database of the list of enrolled students, and also updated periodically.

  3. Provide the ability to find out your status via Telegram, as well as subscribe to changes in information.

So, first we will create our models, which we will store in the database (the simplest SqLite will do as a database).

// Образовательное направление
public class Education
{
    public int Id { get; set; }
    public string Name { get; set; }
    public EducationFormat Format { get; set; }
    public string Code { get; set; }
    public BudgetType BudgetType { get; set; }
    
    //Navigation properties
    public List<Student> Students { get; set; } = [];
}
//Зачисленные счастливчики
public class Student
{
    public int Id { get; set; }
    public string? Snils { get; set; }
    public int RegistrationNumber { get; set; }
    
    //Navigation properties
    public int EducationId { get; set; }
    public Education Education { get; set; }
//Наши подписчики
public class Subscriber
{
    public int Id { get; set; }
    public long ChatId { get; set; }
    public int RegistrationNumber { get; set; }
    public bool IsNotified { get; set; }
}

We set up context and indexes, although given the number of records they are possible and not necessary.

public DbSet<Education> Educations { get; set; }
public DbSet<Student> Students { get; set; }
public DbSet<Subscriber> Subscribers { get; set; }

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Education>().HasIndex(p => p.Code).IsUnique();
    modelBuilder.Entity<Student>().HasIndex(p => p.RegistrationNumber).IsUnique();
    modelBuilder.Entity<Subscriber>().HasIndex(p => p.RegistrationNumber);
}

Our database needs to be initialized with initial data, we need to take all possible training programs with links to enrollment lists from the site. In words, this is simple, but the implementation is complicated by the fact that three types of education (bachelor's, master's and postgraduate) have different structures, which means that instead of one universal parser, we will have to make three, with their own unique rules.

To help you understand, I will give you an example of how visually different the directions of educational programs are.

Directions of study
This is what the list of bachelor's degree programs looks like; there is a table for each type of registration.

This is what the list of bachelor's degree programs looks like; there is a table for each type of registration.

In the Master's program, these are three lists separated by tabs.

In the Master's program, these are three lists separated by tabs.

In graduate school there are two lists, separated by a heading.

In graduate school there are two lists, separated by a heading.

Due to such features, the parsing service turned out to be quite weighty, because it was not possible to unify it, since in addition to the name and link, I wanted to store information about the training format (budget, targeted, paid).

Let's add a builder for compact initialization.

public async Task InitAsync()
{
    int counter = 0;
    foreach (Direction direction in directions)
    {
        Education education = new();
        education.Name = direction.Name;
        education.Format = direction.Format;
        education.Code = direction.Code;
        education.BudgetType = direction.BudgetType;
        if (await db.Educations.AnyAsync(e => e.Code == education.Code))
        {
            logger.LogInformation($"{++counter} Already in db => {education.Name} {education.Code} {education.BudgetType} {education.Format}.");
            continue;
        }
        await db.Educations.AddAsync(education);
        logger.LogInformation($"{++counter} Added to db => {education.Name} {education.Code} {education.BudgetType} {education.Format}.");
    }

    await db.SaveChangesAsync();
}

public  EducationBuilder WithBachelor()
{
    directions.AddRange(parserService.ParseBachelorAsync().GetAwaiter().GetResult());
    return this;
}

public EducationBuilder WithMaster()
{
    directions.AddRange(parserService.ParseMasterAsync().GetAwaiter().GetResult());
    return this;
}
public EducationBuilder WithPostgraduate()
{
    directions.AddRange(parserService.ParsePostgraduateAsync().GetAwaiter().GetResult());
    return this;
}

The result is a pretty neat initialization service.

public async Task InitDb()
{
    await db.Database.MigrateAsync();
    if (!db.Educations.Any())
    {
        await educationBuilder
                .WithBachelor()
                .WithMaster()
                .WithPostgraduate()
                .InitAsync();
    }
}

We are making a service that checks whether a person with a specified number is enrolled (whether there is such a student in our database), which will have a method that works in the background.

public class EnrolledService(AppDbContext db, ParserService parserService,  UrlsConfig urls)
{
public async Task UpdateEnrolledAsync()
{
    List<Education> educations = await db.Educations.ToListAsync();
    foreach (Education education in educations)
    {
        string url = GetEducationLink(education);
        List<EstimationResult?> students = await parserService.ParseAsync(url);
        await AddToDb(students, education);
    }
}

public async Task<EstimationResponseDto?> CheckStudentAsync(int registrationNumber)
{
    var student = await db.Students
        .Include(s => s.Education)
        .FirstOrDefaultAsync(s => s.RegistrationNumber == registrationNumber);
    return student?.StudentInfoToDto();
}
}

Next we need a service that will handle our subscriptions.

public async Task SubscribeAsync(long chatId, int registrationNumber)
{
    if (await db.Subscribers.AnyAsync(s => s.ChatId == chatId))
    {
        throw new Exception("Already subscribed");
    }
    await db.Subscribers.AddAsync(new Subscriber
    {
        ChatId = chatId,
        RegistrationNumber = registrationNumber
    });
    await db.SaveChangesAsync();
}
//При отправке уведомления подписчику вызываем метод, чтобы его не спамить.
public async Task MakeSubscriberNotifiedAsync(Subscriber subscriber)
{
    subscriber.IsNotified = true;
    await db.SaveChangesAsync();
}

public async Task UnsubscribeAsync(long chatId)
{
    var subscriber = await db.Subscribers.FirstOrDefaultAsync(s => s.ChatId == chatId);
    if (subscriber == null)
    {
        throw new Exception("Not subscribed");
    }
    db.Subscribers.Remove(subscriber);
    await db.SaveChangesAsync();
}
public async Task<bool> IsSubscribedAsync(long chatId)
    => await db.Subscribers.AnyAsync(s => s.ChatId == chatId);

public async Task<List<Subscriber>> GetAllActiveSubscribers()
    => await  db.Subscribers.Where(p => !p.IsNotified).ToListAsync();

And we'll add a notification service. We'll send messages and intercept exceptions (for example, if a user subscribed and blocked the bot). In essence, this is a small wrapper over the usual SendTextMessageAsync()

public async Task NotifyAsync(long chatId, string message, ReplyKeyboardMarkup? replyMarkup = null, CancellationToken cancellationToken = default)
{
    try
    {
        await botClient.SendTextMessageAsync(chatId, message, replyMarkup: replyMarkup, parseMode: ParseMode.Html, cancellationToken: cancellationToken);
    }
    catch (ApiRequestException e)
    {
        // Проверяем, не заблокировал ли нас подписчик. И если да, то удаляем его.
        if (e.ErrorCode == 403)
        {
            Subscriber? subscriber = await db.Subscribers.FirstOrDefaultAsync(s => s.ChatId == chatId, cancellationToken: cancellationToken);
            if (subscriber is not null)
            {
                logger.LogWarning($"Chat with id {chatId} blocked the bot and will be unsubscribed forcefully");
                db.Subscribers.Remove(subscriber);
                await db.SaveChangesAsync(cancellationToken);
            }
            logger.LogError(e, $"Error while sending message to chat {chatId}.\n Details: {e.Message}");
        }
    }
    catch (Exception e)
    {
        logger.LogError(e, $"Error while sending message to chat {chatId}.\n Details: {e.Message}");
    }
}

And we set up work on a schedule. I think it is enough to go every half hour and add new enrolled students, if there are any. Well, and immediately check whether we have subscribers among the students in order to send a notification about enrollment, and also automatically exclude from further mailing. For this, I use WorkerService

public abstract class BackgroundWorkerService : BackgroundService
{
    private TaskStatus DoWorkStatus { get; set; } = TaskStatus.Created;
    protected abstract int ExecutionInterval { get; }

    protected override async Task ExecuteAsync(CancellationToken cancellationToken)

    protected abstract Task DoWork(CancellationToken cancellationToken);
}

And then the specific implementation inherits it.

public class UpdaterWorkerService(IServiceScopeFactory scopeFactory) : BackgroundWorkerService
{
    protected override int ExecutionInterval { get; } = (int)TimeSpan.FromMinutes(30).TotalMilliseconds;

    protected override async Task DoWork(CancellationToken cancellationToken)
    {
      ...
    }
}

Well, the most basic thing that any telegram bot has is left – processing incoming requests from the user. I did not bother much with this topic, I took the example from as a basis library documentation.

I also made a HostedService that processes the input data, and made buttons for the user to make input easier. All control was reduced to a switch with context preservation. Since we do not have branched dialogs, this is enough, but for more complex purposes this will be a very long log of conditions.

Final result

Final result

The only thing left to do is deploy our application. We create a new project in Amvera, select a tariff plan and come up with a name.

Creating a new project

Creating a new project

You can skip the initial settings window and configure everything manually. First of all, you need to configure the launch configuration. I use Dockerfile, so I configured it. All you need is to specify the parameters for launching the container, you don’t need to specify docker run itself, you only need arguments and parameters and specify the path to the Dockerfile. I pass the launch from root and connection of secrets as parameters: -d -u root -v /data/4bb1be19-6baf-40ce-9bf9-784d4afcf59a/:/root/.microsoft/usersecrets/4bb1be19-6baf-40ce-9bf9-784d4afcf59a/:ro The secrets themselves can be placed in the Data folder, where you can also place the database so that it remains after deleting the container.

Launch Configuration

Launch Configuration

Now we need to fix the paths in the project so that all the links match. In appsettings.json

"ConnectionStrings": {
  "SqliteConnection": "Data Source=/data/app.db" //указали /data
},

And connect the mounted file with secrets

IConfigurationRoot configuration = new ConfigurationBuilder()
    .AddJsonFile("appsettings.json")
  // добавляем файл, который добавили в /data
    .AddJsonFile("/data/usersecrets/4bb1be19-6baf-40ce-9bf9-784d4afcf59a/secrets.json") 
    .Build();

Well, that's it, the final step is to synchronize the project repository. Just add the external repository with the command

git remote add amvera https://git.amvera.ru/<ваша учетная запись>/<имя проекта>

And then upload the changes, at the same time calling the build start trigger

git push amvera master

Then the build and launch will begin. If there are no mistakes, we will see familiar logs in the console.

In principle, this is enough for our needs. An applicant can check his status through a bot, and also subscribe to a notification if he is on the list of those enrolled in training. What else is needed for student happiness? It remains to hope that next year the structure of the lists will not change much, but for now bot available for verification. The full code is available in repositories.


You can also subscribe to my telegramto stay informed about plans for the release of future articles.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *