Введение

Всем привет!

Данная статья содержит информация о том, как написать телеграм бота на C# с использованием Yandex Cloud Functions и Телеграм Webhook. Также в данной статье будет рассмотрено CI/CD с помощью GitHub Actions.

P.S. полезная литература находится в ссылках!

Причины написания данной статьи

  1. У Yandex Cloud слабая документация к C# коду, поэтому в данной статье хотелось бы более подробно рассказать.

  2. Хочется рассказать о подводных камнях Serverless функций.

Почему Yandex Cloud Functions?

Самое главное преимущество функций в том, что они являются бессерверными (Serverless — более подробно тут), т. е. вам не нужно заботится об управлении сервером. Немаловажным плюсом является и то, что serverless имеет специальный тариф — free tier, он выгоден при малом количестве запросов.

Важное уточнение. Каждый вызов функции выполняется обособлено, т. е. придётся хранить самому состояние бота.

Что было использовано из Yandex Cloud

  • API Gateway — шлюз.

  • YDB — БД для хранения данных.

  • Object Storage — хранилище файлов.

  • Lockbox — сервис по хранению секретов (например ключи доступа).

  • Cloud Function — функция, содержащая код бота.

Если вы хотите создать самого простого бота, то его можно сделать, использовав только Cloud Function.

Настройка Yandex Cloud

Для начала нужно создать сервисный аккаунт, который будет иметь права на тот или иной сервис и от лица которого будут идти все запросы к Yandex Cloud.

В идеале запрос к каждому сервису нужно делать разными сервисными аккаунтами (ради безопасности), чтобы у одного сервисного аккаунта не было МНОГО прав. Но в данной статье не будем заморачиваться, поэтому сделаем один сервисный аккаунт и дадим ему права на сервисы, которые будем использовать. (про сервисные аккаунты, про роли).

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

Опять же, для самого простого бота потребуется только роль serverless.function.invoker.

Рисунок 1 - Роли сервисного аккаунта
Рисунок 1 - Роли сервисного аккаунта

Создание кода на C#

Будем использовать библиотеку Telegram.Bot версии 20.0.0-alpha.1.

Yandex Function обращается к методу FunctionHandler, который находится в классе Handler. FunctionHandler имя константно, т.е. оно заложено в вызываемом коде у яндекса. Имя же пространства имен и класса может быть любое.

На листинге 1 представлен пример с точкой входа и показаны два класса Response и Request. Они содержат структуру принимаемого объекта Request (объект, который присылает яндекс) и возвращаемого объекта Response (объект, который ожидает яндекс). В поле Body класса Response должен быть объект содержащий ActionMethod, ChatId, Text. (такой объект ожидает Telegram).

Код ниже просто дублирует присланные ему сообщения.

Также прошу заметить, что для сериализации данных, Телеграм использует Newtonsoft, а Яндекс использует 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; }
    }
}

Создание Yandex Cloud Function

Создаем функцию в консоли YC (рисунок 3).

Рисунок 3 - Создание функции через Web интерфейс
Рисунок 3 - Создание функции через Web интерфейс

Т.к. в данном примере будет рассматриваться CI/CD, то загрузка окружения будет происходить через Object Storage (рисунок 4).

Рисунок 4 - Создание Object Storage
Рисунок 4 - Создание Object Storage

Далее, после создания bucket заходим в Visual Studio и в окне Developer PowerShell вводим команду dotnet publish -c Release -o publish для публикации проекта. В корне проекта должна появиться папка publish. Ее мы архивируем в zip.

В первый раз publish.zip мы загрузим вручную. Переходим в Object Storage и загружаем архив (рисунок 5).

Рисунок 5 - Архив в Object Storage
Рисунок 5 - Архив в Object Storage

Важно. Нужно, чтобы ваш архив содержал напрямую файлы публикации (рисунок 6)

Рисунок 6 - Структура архива
Рисунок 6 - Структура архива

Переходим в редактор Yandex Function и выбираем среду выполнения - .net 8. Создаем сервисный аккаунт с ролями storage.editor, storage.uploader, functions.functionInvoker.
!!! Важное уточнение. Если код бота будет иметь размер больше 8мб, то окружение надо будет добавлять через zip - либо загружать напрямую, либо через Object Storage (рисунок 7).

Рисунок 7 - Настройка Yandex Cloud Function
Рисунок 7 - Настройка Yandex Cloud Function

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

Далее сохраняем изменения функции.

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

 Рисунок 8 - Включение публичной функции
Рисунок 8 - Включение публичной функции

Заметьте у функции есть ссылка для вызова.Она нам понадобиться, чтобы установить webhook для телеграм бота.

После самостоятельной регистрации бота в телеграм мы получим токен бота (botToken). Теперь установим webhook. Для этого в адресной строке пишем
https://api.telegram.org/bot<botToken>/setWebhook?url=<Ссылка_для_вызова_функции>

После этого вы должны увидеть такую страницу в браузере (рисунок 9).

Рисунок 9 - Успешная установка webhook
Рисунок 9 - Успешная установка webhook

Пример общения с ботом представлен на рисунке 10.

Рисунок 10 - Общение с ботом
Рисунок 10 - Общение с ботом

Запросы к YDB

Инициализация подключения к БД представлена на листинге 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 - это эндпоинт, тип строка

  • Credential.Database - это путь к базе данных, тип строка

  • Credential.JsonUrl - откуда взять файл с авторизованным ключем сервисного аккаунта, тип строка. (его можно получить зайдя в конкретный сервисный аккаунт и создать авторизованный ключ, сохранить файл в формате json и загрузить в Object Storage)

  • Credential.JsonFilePath - локальное хранилище рядом с Cloud Functions (начинается всегда с /tmp, например, /tmp/service_account_key.json)

На рисунке 11 показана информация о YDB.

Рисунок 11 -Информация о YDB
Рисунок 11 -Информация о YDB

Пример вставки данных представлен на листинге 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();
}

Пример получения данных представлен на листинге 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();
}

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

Пример скачивания документов с Telegram представлен на листинге 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);
}

Пример загрузки документов в Object Storage через aws s3 представлен на листинге 6.

  • YaServiceCloudUrl - для яндекса https://s3.yandexcloud.net.

  • YaServiceAuthenticationRegion - для яндекса ru-central1.

  • ACCESS_KEY - открытый ключ сервисного аккаунта (чуть больше 20 символов).

  • SECRET_KEY - закрытый ключ сервисного аккаунта (40 символов)

 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

Добавление api шлюза представлено на рисунке 12.
Если добавляете шлюз, то не нужно забывать про изменение webhook на стороне телеграм.

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

Вместо path_to_function нужно подставить путь до функции, который вы написали в спецификации. В данном примере telegram-bot-function-main.

Рисунок 12 - API Gateway
Рисунок 12 - API Gateway

CI/CD

CI/CD было решено сделать с помощью GitHub Actions. Для секретов было использовано GitHub Secrets.

На листинге 7 представлен 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 — публикация проекта.

  • Set up AWS CLI — скачивание и конфигурация aws.

  • Install Yandex Cloud CLI and dowload zip file to Bucket — установка YC CLI, создание архива из сборки и загрузка его в yandex s3 хранилище (т. е. object storage), создание функции.

Резюмируя

Данная статья написана для ознакомления и написания не сложных Телеграм ботов с использование Yandex Cloud.

Ссылки

Документация сервисов, TelegramAPI, GitHub Actions.

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


  1. ShadowGreg
    24.08.2024 08:11

    Спасибо!


  1. dr-lis
    24.08.2024 08:11

    Спасибо! Однако не совсем ясен момент с использованием AWS здесь: почему бы не использовать вместо него инфраструктуру Yandex?


    1. KrawMire
      24.08.2024 08:11

      Насколько я помню, YandexCloud ObjectStorage использует API от AWS S3. Из-за этого с ним можно (или нужно, тут могу ошибаться) взаимодействовать через AWS CLI (Ссылка на доку YC: https://yandex.cloud/en/docs/storage/tools/aws-cli).

      Плюс, сам Яндекс редко делает какие-либо библиотеки для работы со своими продуктами для .NET (ClickHouse, например), из-за чего для работы с ObjectStorage самый удобный вариант - это использование клиентской библиотеки для AWS S3.


    1. insanity13
      24.08.2024 08:11
      +1

      Инфраструктура AWS здесь не используется. Просто у Яндекс реализована совместимость с некоторыми сервисами AWS.

      В частности с Object Storage можно использовать как S3-bucket AWS: https://yandex.cloud/ru/docs/storage/s3/?utm_referrer=https%3A%2F%2Fyandex.ru%2F


  1. alkozhura
    24.08.2024 08:11

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


    1. insanity13
      24.08.2024 08:11
      +2

      "Привычнее" не значит проще. Справедливо и обратное.
      Лично я не согласен, что, при прочих равных, VPS будет сильно проще в развёртывании.
      Но если бота рассматривать как часть информационной системы, то возникают вопросы:

      • как предлагаете хранить файлы? Локально?
        Как будете организовать доступ к ним из других сервисов? По внутренней сети?
        Наверно, ещё надо не забыть мониторинг свободного места прикрутить.

      • как будете восстанавливать систему на случай сбоя? (Насколько я понял, поднимать вы всё предлагаете вручную. А с сервером за 2 коп. всякое может случиться.)

      • как предлагаете масштабировать систему, в случае необходимости? (имея БД на этом же VPS)

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

      Но соглашусь: оценить стоимость развёрнутого решения в VPS задача тривиальная, а для Я.Облачко - придётся посчитать.

      Как по мне, это очень хорошая задача, чтобы познакомиться с облачными сервисами на практике.