Введение
Всем привет!
Данная статья содержит информация о том, как написать телеграм бота на C# с использованием Yandex Cloud Functions и Телеграм Webhook. Также в данной статье будет рассмотрено CI/CD с помощью GitHub Actions.
P.S. полезная литература находится в ссылках!
Причины написания данной статьи
У Yandex Cloud слабая документация к C# коду, поэтому в данной статье хотелось бы более подробно рассказать.
Хочется рассказать о подводных камнях 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.
Создание кода на 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).
Т.к. в данном примере будет рассматриваться CI/CD, то загрузка окружения будет происходить через Object Storage (рисунок 4).
Далее, после создания bucket заходим в Visual Studio и в окне Developer PowerShell вводим команду dotnet publish -c Release -o publish
для публикации проекта. В корне проекта должна появиться папка publish. Ее мы архивируем в zip.
В первый раз publish.zip мы загрузим вручную. Переходим в Object Storage и загружаем архив (рисунок 5).
Важно. Нужно, чтобы ваш архив содержал напрямую файлы публикации (рисунок 6)
Переходим в редактор Yandex Function и выбираем среду выполнения - .net 8. Создаем сервисный аккаунт с ролями storage.editor, storage.uploader, functions.functionInvoker.
!!! Важное уточнение. Если код бота будет иметь размер больше 8мб, то окружение надо будет добавлять через zip - либо загружать напрямую, либо через Object Storage (рисунок 7).
Можно добавить переменные окружения напрямую в настройках функции, но если настройки содержат пароли, ключи или т.п., то лучше добавлять их через секреты.
Далее сохраняем изменения функции.
Во вкладке обзор нужно обязательно включить Публичная функция. Это нужно для того, чтобы к вашей функции могли обращаться из всеобщего доступа (интернета) (рисунок 8)
Заметьте у функции есть ссылка для вызова.Она нам понадобиться, чтобы установить webhook для телеграм бота.
После самостоятельной регистрации бота в телеграм мы получим токен бота (botToken). Теперь установим webhook. Для этого в адресной строке пишем
https://api.telegram.org/bot<botToken>/setWebhook?url=<Ссылка_для_вызова_функции>
После этого вы должны увидеть такую страницу в браузере (рисунок 9).
Пример общения с ботом представлен на рисунке 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.
Пример вставки данных представлен на листинге 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
.
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.