add architecture skeleton

This commit is contained in:
2026-04-19 13:12:04 +03:00
parent 312708531f
commit 2738f31f26
84 changed files with 2710 additions and 70 deletions
+21
View File
@@ -0,0 +1,21 @@
FROM mcr.microsoft.com/dotnet/runtime:10.0 AS base
USER $APP_UID
WORKDIR /app
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["GymLogAI.TelegramBot/GymLogAI.TelegramBot.csproj", "GymLogAI.TelegramBot/"]
COPY ["GymLogAI.Application/GymLogAI.Application.csproj", "GymLogAI.Application/"]
COPY ["GymLogAI.AI/GymLogAI.AI.csproj", "GymLogAI.AI/"]
COPY ["GymLogAI.Core/GymLogAI.Core.csproj", "GymLogAI.Core/"]
COPY ["GymLogAI.Persistence/GymLogAI.Persistence.csproj", "GymLogAI.Persistence/"]
RUN dotnet restore "GymLogAI.TelegramBot/GymLogAI.TelegramBot.csproj"
COPY . .
WORKDIR "/src/GymLogAI.TelegramBot"
RUN dotnet publish "./GymLogAI.TelegramBot.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
FROM base AS final
WORKDIR /app
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "GymLogAI.TelegramBot.dll"]
@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Telegram.Bot" Version="22.9.6.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\GymLogAI.Application\GymLogAI.Application.csproj" />
<ProjectReference Include="..\GymLogAI.AI\GymLogAI.AI.csproj" />
<ProjectReference Include="..\GymLogAI.Core\GymLogAI.Core.csproj" />
<ProjectReference Include="..\GymLogAI.Persistence\GymLogAI.Persistence.csproj" />
</ItemGroup>
</Project>
+44
View File
@@ -0,0 +1,44 @@
using GymLogAI.AI;
using GymLogAI.Application.Handlers;
using GymLogAI.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Telegram.Bot;
namespace GymLogAI.TelegramBot;
public static class Program
{
public static async Task Main(string[] args)
{
var builder = Host.CreateApplicationBuilder(args);
builder.Services
.AddOptions<TelegramBotOptions>()
.Bind(builder.Configuration.GetSection(TelegramBotOptions.SectionName));
builder.Services.AddPersistence(builder.Configuration);
builder.Services.AddAiServices(builder.Configuration);
builder.Services.AddScoped<ImportTelegramMessageHandler>();
builder.Services.AddScoped<ParseTelegramMessageHandler>();
builder.Services.AddScoped<RecommendWorkoutHandler>();
builder.Services.AddSingleton<ITelegramBotClient>(serviceProvider =>
{
var options = serviceProvider.GetRequiredService<Microsoft.Extensions.Options.IOptions<TelegramBotOptions>>().Value;
return new TelegramBotClient(options.BotToken ?? string.Empty);
});
builder.Services.AddScoped<UpdateHandler>();
builder.Services.AddHostedService<TelegramBotHostedService>();
var host = builder.Build();
await using (var scope = host.Services.CreateAsyncScope())
{
var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await dbContext.Database.MigrateAsync();
}
await host.RunAsync();
}
}
@@ -0,0 +1,53 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Telegram.Bot;
using Telegram.Bot.Polling;
using Telegram.Bot.Types.Enums;
namespace GymLogAI.TelegramBot;
public sealed class TelegramBotHostedService(
ITelegramBotClient botClient,
IServiceProvider serviceProvider,
IOptions<TelegramBotOptions> options,
ILogger<TelegramBotHostedService> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
if (string.IsNullOrWhiteSpace(options.Value.BotToken))
{
logger.LogWarning("Telegram bot token is not configured. Bot polling is disabled.");
return;
}
var receiverOptions = new ReceiverOptions
{
AllowedUpdates = [UpdateType.Message]
};
botClient.StartReceiving(
async (client, update, cancellationToken) =>
{
await using var scope = serviceProvider.CreateAsyncScope();
var handler = scope.ServiceProvider.GetRequiredService<UpdateHandler>();
await handler.HandleUpdateAsync(client, update, cancellationToken);
},
async (client, exception, cancellationToken) =>
{
await using var scope = serviceProvider.CreateAsyncScope();
var handler = scope.ServiceProvider.GetRequiredService<UpdateHandler>();
await handler.HandleErrorAsync(client, exception, cancellationToken);
},
receiverOptions,
stoppingToken);
logger.LogInformation("Telegram bot long polling started.");
while (!stoppingToken.IsCancellationRequested)
{
await Task.Delay(TimeSpan.FromSeconds(options.Value.PollingTimeoutSeconds), stoppingToken);
}
}
}
@@ -0,0 +1,9 @@
namespace GymLogAI.TelegramBot;
public sealed record TelegramBotOptions
{
public const string SectionName = "TelegramBot";
public string? BotToken { get; init; }
public int PollingTimeoutSeconds { get; init; } = 5;
}
+82
View File
@@ -0,0 +1,82 @@
using GymLogAI.Application.Handlers;
using GymLogAI.Core.Enums;
using Microsoft.Extensions.Logging;
using Telegram.Bot;
using Telegram.Bot.Types;
using Telegram.Bot.Types.Enums;
namespace GymLogAI.TelegramBot;
public sealed class UpdateHandler(
ImportTelegramMessageHandler importTelegramMessageHandler,
RecommendWorkoutHandler recommendWorkoutHandler,
ILogger<UpdateHandler> logger)
{
public async Task HandleUpdateAsync(ITelegramBotClient botClient, Update update, CancellationToken cancellationToken)
{
if (update.Type != UpdateType.Message || update.Message?.Text is null)
{
return;
}
var message = update.Message;
var text = message.Text.Trim();
if (text.Equals("/start", StringComparison.OrdinalIgnoreCase))
{
await botClient.SendMessage(
message.Chat.Id,
"GymLogAI bot is ready. Send workout text or use /today for a recommendation.",
cancellationToken: cancellationToken);
return;
}
if (text.Equals("/today", StringComparison.OrdinalIgnoreCase))
{
var userId = CreateUserId(message.From?.Id ?? message.Chat.Id);
var recommendation = await recommendWorkoutHandler.HandleAsync(
new RecommendWorkoutCommand(userId, GoalType.Maintain, "Requested from Telegram command /today"),
cancellationToken);
await botClient.SendMessage(
message.Chat.Id,
$"{recommendation.Title}\n{recommendation.Summary}\nExercises: {string.Join(", ", recommendation.Exercises)}",
cancellationToken: cancellationToken);
return;
}
var importedMessageId = await importTelegramMessageHandler.HandleAsync(
new ImportTelegramMessageCommand(
CreateUserId(message.From?.Id ?? message.Chat.Id),
message.From?.Id ?? message.Chat.Id,
message.Chat.Id,
message.MessageId,
text,
message.Date.ToUniversalTime(),
message.From?.Username,
message.From?.FirstName,
message.From?.LastName),
cancellationToken);
logger.LogInformation("Imported telegram message {MessageId} from chat {ChatId}", importedMessageId, message.Chat.Id);
await botClient.SendMessage(
message.Chat.Id,
"Message imported. It will be parsed by the worker shortly.",
cancellationToken: cancellationToken);
}
public Task HandleErrorAsync(ITelegramBotClient botClient, Exception exception, CancellationToken cancellationToken)
{
logger.LogError(exception, "Telegram bot polling error");
return Task.CompletedTask;
}
private static Guid CreateUserId(long telegramUserId)
{
Span<byte> buffer = stackalloc byte[16];
BitConverter.TryWriteBytes(buffer[..8], telegramUserId);
BitConverter.TryWriteBytes(buffer[8..], telegramUserId);
return new Guid(buffer);
}
}
@@ -0,0 +1,9 @@
{
"ConnectionStrings": {
"Postgres": "Host=localhost;Port=5432;Database=gymlogai;Username=postgres;Password=postgres"
},
"TelegramBot": {
"BotToken": "",
"PollingTimeoutSeconds": 5
}
}
+21
View File
@@ -0,0 +1,21 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.Hosting.Lifetime": "Information"
}
},
"ConnectionStrings": {
"Postgres": "Host=localhost;Port=5432;Database=gymlogai;Username=postgres;Password=postgres"
},
"TelegramBot": {
"BotToken": "",
"PollingTimeoutSeconds": 5
},
"OpenRouter": {
"BaseUrl": "https://openrouter.ai/api/v1/",
"ApiKey": "",
"ChatModel": "openai/gpt-4.1-mini",
"EmbeddingModel": "text-embedding-3-small"
}
}