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
Yandex Cloud has poor documentation for C# code, so in this article I would like to go into more detail.
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
API Gateway – gateway.
YDB — Database for data storage.
Object Storage — file storage.
Lockbox — a service for storing secrets (for example, access keys).
Cloud Function — a function containing the bot code.
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.
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).
Since this example will consider CI/CD, the environment will be loaded via Object Storage (Figure 4).
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).
Important: Your archive must contain the publication files directly (Figure 6)
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).
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)
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
After that, you should see a page like this in your browser (Figure 9).
An example of communication with a bot is shown in Figure 10.
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.
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
.
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.