Telegram bot in C# using Yandex Cloud Functions

Introduction

Hi all!

This article contains information on how to write a telegram bot in C# using Yandex Cloud Functions and Telegram Webhook. This article will also cover CI/CD using GitHub Actions.

P.S. Useful literature is in the links!

Reasons for writing this article

  1. Yandex Cloud has poor documentation for C# code, so in this article I would like to go into more detail.

  2. I would like to talk about the pitfalls of Serverless functions.

Why Yandex Cloud Functions?

The main advantage of functions is that they are serverless (Serverless – more details here), i.e. you don't need to worry about server management. An important advantage is that serverless has a special tariff – free tierit is beneficial for a small number of requests.

Important clarification. Each function call is performed separately, i.e. you will have to store the bot state yourself.

What was used from Yandex Cloud

If you want to create the simplest bot, you can do it using only Cloud Function.

Setting up Yandex Cloud

First, you need to create a service account that will have rights to a particular service and on behalf of which all requests to Yandex Cloud will be sent.

Ideally, requests to each service should be made by different service accounts (for security reasons), so that one service account does not have MANY rights. But in this article, we will not bother with this, so we will make one service account and give it rights to the services that we will use. (about service accountsabout roles).

Figure 1 shows a service account with the necessary roles. These roles are given based on the services you will use. You can read about each role in the documentation.

Again, the simplest bot would only require the serverless.function.invoker role.

Figure 1 - Service Account Roles

Figure 1 – Service Account Roles

Creating C# code

We will use the Telegram.Bot library version 20.0.0-alpha.1.

Yandex Function refers to the FunctionHandler method, which is in the Handler class. FunctionHandler name is constant, i.e. it is embedded in the called code in Yandex. The name of the namespace and class can be any.

Listing 1 shows an example with an entry point and shows two classes, Response and Request. They contain the structure of the received Request object (the object that sends Yandex) and the returned Response object (the object that is expected Yandex). The Body field of the Response class must contain an object containing ActionMethod, ChatId, Text. (Telegram expects such an object).

The code below simply duplicates the messages sent to him.

Also please note that for data serialization, Telegram uses Newtonsoft, and Yandex uses Text.Json.Serialization.

using Newtonsoft.Json;
using System.Text.Json.Serialization;
using Telegram.Bot.Types;

namespace SensibleBot
{
    public class Handler
    {
        public async Task<Response> FunctionHandler(Request context)
        {
            try
            {
                var update = JsonConvert.DeserializeObject<Update>(context.body);
                var answer = JsonConvert.SerializeObject(new Answer("sendMessage", update.Message.Chat.Id, update.Message.Text));
                return new Response(200, answer, new Header("application/json"), false);
            }
            catch (Exception e)
            {
                return new Response(500, e.Message, new Header("application/json"), false);
            }
        }

        public class Request
        {
            public string httpMethod { get; set; }
            public string body { get; set; }
        }

        public class Response
        {
            public Response(int statusCode, string body, Header headers, bool isBase64Encoded)
            {
                StatusCode = statusCode;
                Body = body;
                Headers = headers;
                IsBase64Encoded = isBase64Encoded;
            }

            [JsonPropertyName("statusCode")]
            public int StatusCode { get; set; }

            [JsonPropertyName("body")]
            public string Body { get; set; }

            [JsonPropertyName("headers")]
            public Header Headers { get; set; }

            [JsonPropertyName("isBase64Encoded")]
            public bool IsBase64Encoded { get; set; }
        }
    }
    public class Header
    {
        public Header(string contentType = "application/json")
        {
            ContentType = contentType;
        }

        [JsonPropertyName("Content-Type")]
        public string ContentType { get; set; }
    }

    public class Answer
    {
        public Answer(string method, long chatId, string text)
        {
            Method = method;
            ChatId = chatId;
            Text = text;
        }

        [JsonProperty("method")]
        public string Method { get; set; }

        [JsonProperty("chat_id")]
        public long ChatId { get; set; }

        [JsonProperty("text")]
        public string Text { get; set; }
    }
}

Creating Yandex Cloud Function

We create a function in the YC console (Figure 3).

Figure 3 - Creating a function via the Web interface

Figure 3 – Creating a function via the Web interface

Since this example will consider CI/CD, the environment will be loaded via Object Storage (Figure 4).

Figure 4 - Creating Object Storage

Figure 4 – Creating Object Storage

Next, after creating the bucket, go to Visual Studio and enter the command in the Developer PowerShell window dotnet publish -c Release -o publish to publish the project. A publish folder should appear in the root of the project. We archive it in zip.

The first time we will upload publish.zip manually. Go to Object Storage and upload the archive (Figure 5).

Figure 5 - Archive in Object Storage

Figure 5 – Archive in Object Storage

Important: Your archive must contain the publication files directly (Figure 6)

Figure 6 - Archive structure

Figure 6 – Archive structure

Go to the Yandex Function editor and select the execution environment – .net 8. Create a service account with the roles storage.editor, storage.uploader, functions.functionInvoker.
!!! Important clarification. If the bot code is larger than 8 MB, the environment will need to be added via zip – either downloaded directly or via Object Storage (Figure 7).

Figure 7 - Setting up Yandex Cloud Function

Figure 7 – Setting up Yandex Cloud Function

You can add environment variables directly in the function settings, but if the settings contain passwords, keys, etc., it is better to add them through secrets.

Next, we save the changes to the function.

In the overview tab, you must enable Public function. This is necessary so that your function can be accessed from the public (Internet) (Figure 8)

  Figure 8 - Enabling a public function

Figure 8 – Enabling a public function

Note that the function has a call link. We will need it to set up a webhook for the telegram bot.

After self-registering the bot in Telegram, we will receive a bot token (botToken). Now let's install the webhook. To do this, write in the address bar
https://api.telegram.org/bot/setWebhook?url=

After that, you should see a page like this in your browser (Figure 9).

Figure 9 - Successful webhook installation

Figure 9 – Successful webhook installation

An example of communication with a bot is shown in Figure 10.

Figure 10 - Communication with the bot

Figure 10 – Communication with the bot

Queries to YDB

Initialization of the connection to the database is shown in Listing 2.

using Amazon.S3;
using Amazon.S3.Model;
using Telegram.Bot.Types;
using Ydb.Sdk;
using Ydb.Sdk.Auth;
using Ydb.Sdk.Services.Table;
using Ydb.Sdk.Value;
using Ydb.Sdk.Yc;

namespace SensibleBot.DbContext
{
  public static class YdbContext
  {
      public static async Task<TableClient> Initialize(StaticCredentialsProvider staticCredentialsProvider = null)
      {
          // Download JSON-file (with access and secret keys) to temp from Object Storage
          await DownloadFileAndWriteToTemp(Credential.JsonUrl, Credential.JsonFilePath);
  
          var scp = new ServiceAccountProvider(Credential.JsonFilePath);
  
          return await Run(Credential.Endpoint, Credential.Database, scp);
      }
  
      private static async Task DownloadFileAndWriteToTemp(string sourceUrl, string destinationOutputPath)
      {
          var httpClient = new HttpClient();
          using (var response = await httpClient.GetAsync(sourceUrl))
          {
              response.EnsureSuccessStatusCode();
              var content = await response.Content.ReadAsByteArrayAsync();
              await System.IO.File.WriteAllBytesAsync(destinationOutputPath, content);
          }
      }
  
      public static async Task<TableClient> Run(string endpoint, string database, ICredentialsProvider credentialsProvider = null)
      {
          var config = new DriverConfig(
              endpoint: endpoint,
              database: database,
              credentials: credentialsProvider
          );
  
          using var driver = new Driver(
              config: config
          );
  
          await driver.Initialize();
          using var tableClient = new TableClient(driver, new TableClientConfig());
  
          return tableClient;
      }
  }
}
  • Credential.Endpoint is an endpoint, type string

  • Credential.Database is the path to the database, type string

  • Credential.JsonUrl – where to get the file with the authorized key of the service account, string type. (You can get it by logging into a specific service account and creating an authorized key, saving the file in json format and uploading it to Object Storage)

  • Credential.JsonFilePath – local storage next to Cloud Functions (always starts with /tmp, for example, /tmp/service_account_key.json)

Figure 11 shows information about YDB.

Figure 11 - Information about YDB

Figure 11 – Information about YDB

An example of data insertion is shown in Listing 3.

public static async Task SaveStepToDB(TableClient tableClient)
{
    var newCommand = new Command
    {
        ChatId = 123,
        Text = "exmp",

        Date = DateTime.UtcNow.Ticks,
        CommandName = "/start",
        StepNumber = 12,

        UserId = 1,

        IsFinalStep = false
    };

    var response = await tableClient.SessionExec(async session =>
    {
        var query = @"DECLARE $chatId AS Int64;
                                                DECLARE $text AS Optional<Utf8>;
                                                DECLARE $isFinalStep AS Optional<Bool>;

                                                DECLARE $date AS Int64;
                                                DECLARE $commandName AS Utf8;
                                                DECLARE $stepNumber AS Int16;

                                                DECLARE $userId AS Int64;  

                                                INSERT INTO Commands (Text , Date ,  UserId , ChatId , StepNumber , CommandName, IsFinalStep) VALUES
                                                    ($text, $date, $userId, $chatId, $stepNumber, $commandName, $isFinalStep );";

        return await session.ExecuteDataQuery(
            query: query,
            txControl: TxControl.BeginSerializableRW().Commit(),
            parameters: new Dictionary<string, YdbValue>
                {
                    { "$text", YdbValue.MakeOptionalUtf8(newCommand.Text) },
                    { "$date", YdbValue.MakeInt64(newCommand.Date) },
                    { "$userId", YdbValue.MakeInt64(newCommand.UserId) },
                    { "$chatId", YdbValue.MakeInt64(newCommand.ChatId) },
                    { "$stepNumber", YdbValue.MakeInt16(newCommand.StepNumber) },
                    { "$commandName", YdbValue.MakeUtf8(newCommand.CommandName) },
                    { "$isFinalStep", YdbValue.MakeOptionalBool(newCommand.IsFinalStep) },
                }
        );
    });

    response.Status.EnsureSuccess();
}

An example of data retrieval is shown in Listing 4.

public static async Task<Command?> GetLastCommand(TableClient tableClient)
{
    var response = await tableClient.SessionExec(async session =>
    {
        var query = @" DECLARE $userId AS Int64;  DECLARE $chatId AS Int64; 
                                                                SELECT Date, StepNumber,CommandName, IsFinalStep
                                                                FROM Commands 
                                                                WHERE UserId = $userId and ChatId = $chatId
                                                                ORDER BY Date DESC
                                                                LIMIT 1";

        return await session.ExecuteDataQuery(query,
                                              TxControl.BeginSerializableRW().Commit(),
                                              parameters: new Dictionary<string, YdbValue>
                                              {
                                                  { "$userId", YdbValue.MakeInt64(11) },
                                                  { "$chatId", YdbValue.MakeInt64(12) }
                                              });
    });

    response.Status.EnsureSuccess();
    var queryResponse = (ExecuteDataQueryResponse)response;
    var resultSet = queryResponse.Result.ResultSets[0];

    return resultSet.Rows.Select(x => new Command
    {
        StepNumber = (short)x["StepNumber"],
        CommandName = (string)x["CommandName"],
        IsFinalStep = (bool?)x["IsFinalStep"],
    }).FirstOrDefault();
}

When a user uploads a document in Telegram, the function receives not a full document, but only information about it (from this information we only need fileId). Using fileId, we can download a document from Telegram and do whatever we want with it.

An example of downloading documents from Telegram is shown in Listing 5.

using Telegram.Bot;

public static async Task<Models.Telegram.FileInfo> DownloadFile(string fileId)
{
    TelegramBotClient d = new TelegramBotClient(new TelegramBotClientOptions(Credential.BotToken));

    var fileInfo = await d.GetFileAsync(fileId);

    var fileFullName = fileInfo.FilePath.Split("/").Last();
    var fileName = fileFullName.Split(".").First();
    var fileExt = fileFullName.Split(".").Last();

    using var ms = new MemoryStream();
    await d.DownloadFileAsync(fileInfo.FilePath, ms);
    ms.Seek(0, SeekOrigin.Begin);

    return new Models.Telegram.FileInfo(ms.ToArray(), fileExt, fileName);
}

An example of uploading documents to Object Storage via aws s3 is shown in Listing 6.

  • YaServiceCloudUrl – for Yandex https://s3.yandexcloud.net.

  • YaServiceAuthenticationRegion – for Yandex ru-central1.

  • ACCESS_KEY – public key of the service account (a little more than 20 characters).

  • SECRET_KEY – private key of the service account (40 characters)

 public static async Task SaveFile(Models.Telegram.FileInfo fileInfo, string contentType, string folderName)
 {
     try
     {
         AmazonS3Config configsS3 = new AmazonS3Config
         {
             ServiceURL = Credential.YaServiceCloudUrl,
             ForcePathStyle = true,
             AuthenticationRegion = Credential.YaServiceAuthenticationRegion
         };

         AmazonS3Client s3Client = new AmazonS3Client(
             Environment.GetEnvironmentVariable("ACCESS_KEY"),
             Environment.GetEnvironmentVariable("SECRET_KEY"),
             configsS3);

         var putRequest = new PutObjectRequest
         {
             BucketName = Credential.YaBucketName,
             ContentType = contentType,
             InputStream = new MemoryStream(fileInfo.File),
             Key = $"{folderName}/{fileInfo.FileName}",
         };

         var response = await s3Client.PutObjectAsync(putRequest);
     }
     catch (AmazonS3Exception e)
     {
         Console.WriteLine("Error encountered ***. Message:'{0}' when writing an object", e.Message);
     }
     catch (Exception e)
     {
         Console.WriteLine("Unknown encountered on server. Message:'{0}' when writing an object", e.Message);
     }
 }

API Gateway

Adding an API gateway is shown in Figure 12.
If you add a gateway, you should not forget about changing the webhook on the Telegram side.

https://api.telegram.org/bot<botToken>/setWebhook?url=<Служебный_домен_api_gateway>/<path_to_function>

Instead of path_to_function you need to substitute the path to the function that you wrote in the specification. In this example telegram-bot-function-main.

Figure 12 - API Gateway

Figure 12 – API Gateway

CI/CD

It was decided to do CI/CD using GitHub Actions. For secrets, GitHub Secrets was used.

Listing 7 shows the pipeline action.

name: Deploy Telegram SensibleDev bot to Yandex Cloud Function

on:
  push:
    branches:
      - main

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest

    steps:
    - name: Checkout code
      uses: actions/checkout@v3

    - name: Set up .NET Core
      uses: actions/setup-dotnet@v3
      with:
        dotnet-version: '8.x'

    - name: Restore dependencies
      run: dotnet restore

    - name: Build project
      run: dotnet publish -c Release -o output

    - name: Set up AWS CLI
      env:
        AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
        AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
        AWS_DEFAULT_REGION: ru-central1
      run: |
        sudo apt-get update
        sudo apt-get install -y awscli
        aws configure set aws_access_key_id $AWS_ACCESS_KEY_ID
        aws configure set aws_secret_access_key $AWS_SECRET_ACCESS_KEY
        aws configure set default.region $AWS_DEFAULT_REGION
        aws configure set default.s3.endpoint_url https://s3.yandexcloud.net

    - name: Install Yandex Cloud CLI and dowload zip file to Bucket
      run: |
        curl https://storage.yandexcloud.net/yandexcloud-yc/install.sh | bash
        echo 'export PATH=$HOME/yandex-cloud/bin:$PATH' >> $GITHUB_ENV
        export PATH=$HOME/yandex-cloud/bin:$PATH
        source $GITHUB_ENV
        source ~/.bashrc
        which yc
        yc --version
        yc config set token ${{ secrets.YC_OAUTH_TOKEN }}
        ZIP_FILE=output.zip
        cd output 
        zip -r $ZIP_FILE *
        aws s3 cp $ZIP_FILE s3://${{ secrets.YC_BUCKET_NAME }}/$ZIP_FILE --acl private --endpoint-url https://s3.yandexcloud.net
        yc serverless function version create \
          --function-id ${{ secrets.YC_FUNCTION_ID }} \
          --runtime dotnet8 \
          --entrypoint SensibleBot.Handler \
          --memory 896m \
          --execution-timeout 20s \
          --package-bucket-name ${{ secrets.YC_BUCKET_NAME }} \
          --package-object-name output.zip \
          --folder-id ${{ secrets.YC_FOLDER_ID }} \
          --service-account-id ${{ secrets.YC_SERVICE_ACCOUNT_ID }} \
          --secret environment-variable=ACCESS_KEY,id=e6q97h****,version-id=e6q1m1toptc****,key=ACCESS-KEY-SENSIBLE-TG-**** \
          --secret environment-variable=SECRET_KEY,id=e6q97h****,version-id=e6q1m1toptc****,key=SECRET-KEY-SENSIBLE-TG-****
  • Build project – publication of the project.

  • Set up AWS CLI — downloading and configuring aws.

  • Install Yandex Cloud CLI and dowload zip file to Bucket — installing YC CLI, creating an archive from the assembly and uploading it to Yandex S3 storage (i.e. object storage), creating a function.

To sum up

This article is written for familiarization and writing of simple Telegram bots using Yandex Cloud.

Links

Service documentation, TelegramAPI, GitHub Actions.

Similar Posts

Leave a Reply

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