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
+33
View File
@@ -0,0 +1,33 @@
using GymLogAI.Application.Interfaces;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace GymLogAI.AI;
public static class DependencyInjection
{
public static IServiceCollection AddAiServices(this IServiceCollection services, IConfiguration configuration)
{
services
.AddOptions<OpenRouterOptions>()
.Bind(configuration.GetSection(OpenRouterOptions.SectionName));
services.AddHttpClient<OpenRouterChatClient>((serviceProvider, client) =>
{
var options = serviceProvider.GetRequiredService<Microsoft.Extensions.Options.IOptions<OpenRouterOptions>>().Value;
client.BaseAddress = new Uri(options.BaseUrl);
});
services.AddHttpClient<OpenRouterEmbeddingClient>((serviceProvider, client) =>
{
var options = serviceProvider.GetRequiredService<Microsoft.Extensions.Options.IOptions<OpenRouterOptions>>().Value;
client.BaseAddress = new Uri(options.BaseUrl);
});
services.AddScoped<IWorkoutParser, WorkoutParserAIService>();
services.AddScoped<IWorkoutRecommendationGenerator, WorkoutRecommendationAIService>();
services.AddScoped<IEmbeddingService, StubEmbeddingService>();
return services;
}
}
+19
View File
@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\GymLogAI.Application\GymLogAI.Application.csproj" />
<ProjectReference Include="..\GymLogAI.Core\GymLogAI.Core.csproj" />
</ItemGroup>
</Project>
+36
View File
@@ -0,0 +1,36 @@
using System.Net.Http.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace GymLogAI.AI;
public sealed class OpenRouterChatClient(
HttpClient httpClient,
IOptions<OpenRouterOptions> options,
ILogger<OpenRouterChatClient> logger)
{
private readonly OpenRouterOptions _options = options.Value;
public async Task<string?> CompleteAsync(string prompt, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(_options.ApiKey))
{
logger.LogInformation("OpenRouter API key is not configured. Returning stubbed chat completion.");
return null;
}
using var request = new HttpRequestMessage(HttpMethod.Post, "chat/completions")
{
Content = JsonContent.Create(new
{
model = _options.ChatModel,
messages = new[] { new { role = "user", content = prompt } }
})
};
request.Headers.Authorization = new("Bearer", _options.ApiKey);
var response = await httpClient.SendAsync(request, cancellationToken);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync(cancellationToken);
}
}
+37
View File
@@ -0,0 +1,37 @@
using System.Net.Http.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace GymLogAI.AI;
public sealed class OpenRouterEmbeddingClient(
HttpClient httpClient,
IOptions<OpenRouterOptions> options,
ILogger<OpenRouterEmbeddingClient> logger)
{
private readonly OpenRouterOptions _options = options.Value;
public async Task<float[]?> GenerateAsync(string input, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(_options.ApiKey))
{
logger.LogInformation("OpenRouter API key is not configured. Returning stubbed embedding.");
return null;
}
using var request = new HttpRequestMessage(HttpMethod.Post, "embeddings")
{
Content = JsonContent.Create(new
{
model = _options.EmbeddingModel,
input
})
};
request.Headers.Authorization = new("Bearer", _options.ApiKey);
var response = await httpClient.SendAsync(request, cancellationToken);
response.EnsureSuccessStatusCode();
return null;
}
}
+11
View File
@@ -0,0 +1,11 @@
namespace GymLogAI.AI;
public sealed class OpenRouterOptions
{
public const string SectionName = "OpenRouter";
public string BaseUrl { get; init; } = "https://openrouter.ai/api/v1/";
public string? ApiKey { get; init; }
public string ChatModel { get; init; } = "openai/gpt-4.1-mini";
public string EmbeddingModel { get; init; } = "text-embedding-3-small";
}
+23
View File
@@ -0,0 +1,23 @@
using GymLogAI.Application.Interfaces;
namespace GymLogAI.AI;
public sealed class StubEmbeddingService(OpenRouterEmbeddingClient embeddingClient) : IEmbeddingService
{
public async Task<float[]> GenerateEmbeddingAsync(string input, CancellationToken cancellationToken)
{
var remoteEmbedding = await embeddingClient.GenerateAsync(input, cancellationToken);
if (remoteEmbedding is not null)
{
return remoteEmbedding;
}
return
[
input.Length,
input.Count(char.IsLetter),
input.Count(char.IsDigit),
input.Count(char.IsWhiteSpace)
];
}
}
+36
View File
@@ -0,0 +1,36 @@
using GymLogAI.Application.DTOs;
using GymLogAI.Application.Interfaces;
namespace GymLogAI.AI;
public sealed class WorkoutParserAIService(OpenRouterChatClient chatClient) : IWorkoutParser
{
public async Task<ParsedWorkoutDto?> ParseAsync(string text, CancellationToken cancellationToken)
{
await chatClient.CompleteAsync($"Parse workout:\n{text}", cancellationToken);
var lines = text
.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(x => !string.IsNullOrWhiteSpace(x))
.ToArray();
if (lines.Length == 0)
{
return null;
}
var exercises = lines
.Select((line, index) => new ParsedExerciseDto(
line,
index + 1,
null,
[new ParsedExerciseSetDto(1, 10, null, null, null, "Stub set")]))
.ToArray();
return new ParsedWorkoutDto(
DateTime.UtcNow,
"Imported workout",
"Stubbed parser result",
exercises);
}
}
@@ -0,0 +1,34 @@
using GymLogAI.Application.DTOs;
using GymLogAI.Application.Interfaces;
using GymLogAI.Core.Entities;
namespace GymLogAI.AI;
public sealed class WorkoutRecommendationAIService(OpenRouterChatClient chatClient) : IWorkoutRecommendationGenerator
{
public async Task<WorkoutRecommendationDto> GenerateAsync(
WorkoutRecommendationContext context,
IReadOnlyCollection<Workout> workoutHistory,
CancellationToken cancellationToken)
{
await chatClient.CompleteAsync(
$"Recommend workout for goal {context.GoalType} with {workoutHistory.Count} historical workouts.",
cancellationToken);
var lastWorkout = workoutHistory.FirstOrDefault();
var fallbackExercises = lastWorkout?.WorkoutExercises
.OrderBy(x => x.Order)
.Select(x => x.Exercise?.Name ?? "Bodyweight movement")
.Distinct()
.Take(4)
.ToArray();
return new WorkoutRecommendationDto(
"Today's training recommendation",
$"Goal: {context.GoalType}. History count: {workoutHistory.Count}.",
fallbackExercises is { Length: > 0 }
? fallbackExercises
: ["Squat", "Bench Press", "Row", "Plank"],
"Keep the session moderate and leave 1-2 reps in reserve.");
}
}
@@ -0,0 +1,14 @@
namespace GymLogAI.API.Contracts;
public sealed record ImportTelegramMessageRequest(
Guid UserId,
long TelegramUserId,
long TelegramChatId,
int TelegramMessageId,
string Text,
DateTime SentAtUtc,
string? Username,
string? FirstName,
string? LastName);
public sealed record ImportTelegramMessageResponse(Guid MessageId);
@@ -0,0 +1,8 @@
using GymLogAI.Core.Enums;
namespace GymLogAI.API.Contracts;
public sealed record RecommendWorkoutRequest(
Guid UserId,
GoalType GoalType,
string? AdditionalContext);
@@ -0,0 +1,23 @@
namespace GymLogAI.API.Contracts;
public sealed record WorkoutHistoryItemResponse(
Guid Id,
DateTime PerformedAtUtc,
string? Title,
string? Notes,
IReadOnlyCollection<WorkoutHistoryExerciseResponse> Exercises);
public sealed record WorkoutHistoryExerciseResponse(
Guid ExerciseId,
string Name,
int Order,
string? Notes,
IReadOnlyCollection<WorkoutHistorySetResponse> Sets);
public sealed record WorkoutHistorySetResponse(
int Order,
int? Repetitions,
decimal? WeightKg,
int? DurationSeconds,
decimal? DistanceMeters,
string? Notes);
+4
View File
@@ -8,6 +8,10 @@ FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG BUILD_CONFIGURATION=Release ARG BUILD_CONFIGURATION=Release
WORKDIR /src WORKDIR /src
COPY ["GymLogAI.API/GymLogAI.API.csproj", "GymLogAI.API/"] COPY ["GymLogAI.API/GymLogAI.API.csproj", "GymLogAI.API/"]
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.API/GymLogAI.API.csproj" RUN dotnet restore "GymLogAI.API/GymLogAI.API.csproj"
COPY . . COPY . .
WORKDIR "/src/GymLogAI.API" WORKDIR "/src/GymLogAI.API"
+5 -1
View File
@@ -9,6 +9,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.6" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.6" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -18,7 +19,10 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\GymLogAI.TgBot\GymLogAI.TgBot.csproj" /> <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> </ItemGroup>
</Project> </Project>
+94 -25
View File
@@ -1,51 +1,120 @@
using GymLogAI.TgBot; using GymLogAI.AI;
using GymLogAI.API.Contracts;
using GymLogAI.Application.Handlers;
using GymLogAI.Application.Interfaces;
using GymLogAI.Persistence;
using Microsoft.EntityFrameworkCore;
namespace GymLogAI.API; namespace GymLogAI.API;
public class Program public class Program
{ {
public static void Main(string[] args) public static async Task Main(string[] args)
{ {
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
// Add services to the container. builder.Services.AddProblemDetails();
builder.Services.AddAuthorization(); builder.Services.AddAuthorization();
builder.Services.AddTgBot(builder.Configuration);
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
builder.Services.AddOpenApi(); builder.Services.AddOpenApi();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddPersistence(builder.Configuration);
builder.Services.AddAiServices(builder.Configuration);
builder.Services.AddScoped<ImportTelegramMessageHandler>();
builder.Services.AddScoped<ParseTelegramMessageHandler>();
builder.Services.AddScoped<RecommendWorkoutHandler>();
var app = builder.Build(); var app = builder.Build();
// Configure the HTTP request pipeline. await using (var scope = app.Services.CreateAsyncScope())
{
var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await dbContext.Database.MigrateAsync();
}
if (app.Environment.IsDevelopment()) if (app.Environment.IsDevelopment())
{ {
app.MapOpenApi(); app.MapOpenApi();
} }
app.UseExceptionHandler();
app.UseSwagger();
app.UseSwaggerUI();
app.UseHttpsRedirection(); app.UseHttpsRedirection();
app.UseAuthorization(); app.UseAuthorization();
var summaries = new[] var api = app.MapGroup("/api");
{
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
};
app.MapGet("/weatherforecast", (HttpContext httpContext) => api.MapPost("/messages/import", async (
ImportTelegramMessageRequest request,
ImportTelegramMessageHandler handler,
CancellationToken cancellationToken) =>
{ {
var forecast = Enumerable.Range(1, 5).Select(index => var id = await handler.HandleAsync(
new WeatherForecast new ImportTelegramMessageCommand(
{ request.UserId,
Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)), request.TelegramUserId,
TemperatureC = Random.Shared.Next(-20, 55), request.TelegramChatId,
Summary = summaries[Random.Shared.Next(summaries.Length)] request.TelegramMessageId,
}) request.Text,
.ToArray(); request.SentAtUtc,
return forecast; request.Username,
}) request.FirstName,
.WithName("GetWeatherForecast"); request.LastName),
cancellationToken);
app.Run(); return TypedResults.Accepted($"/api/messages/{id}", new ImportTelegramMessageResponse(id));
})
.WithName("ImportTelegramMessage");
api.MapPost("/workouts/recommend", async (
RecommendWorkoutRequest request,
RecommendWorkoutHandler handler,
CancellationToken cancellationToken) =>
{
var result = await handler.HandleAsync(
new RecommendWorkoutCommand(request.UserId, request.GoalType, request.AdditionalContext),
cancellationToken);
return TypedResults.Ok(result);
})
.WithName("RecommendWorkout");
api.MapGet("/workouts/history", async (
Guid userId,
IWorkoutRepository workoutRepository,
CancellationToken cancellationToken) =>
{
var history = await workoutRepository.GetHistoryAsync(userId, cancellationToken);
var response = history.Select(workout => new WorkoutHistoryItemResponse(
workout.Id,
workout.PerformedAtUtc,
workout.Title,
workout.Notes,
workout.WorkoutExercises
.OrderBy(x => x.Order)
.Select(x => new WorkoutHistoryExerciseResponse(
x.ExerciseId,
x.Exercise?.Name ?? "Unknown exercise",
x.Order,
x.Notes,
x.Sets.OrderBy(s => s.Order)
.Select(s => new WorkoutHistorySetResponse(
s.Order,
s.Repetitions,
s.WeightKg,
s.DurationSeconds,
s.DistanceMeters,
s.Notes))
.ToArray()))
.ToArray()))
.ToArray();
return TypedResults.Ok(response);
})
.WithName("GetWorkoutHistory");
await app.RunAsync();
} }
} }
-12
View File
@@ -1,12 +0,0 @@
namespace GymLogAI.API;
public class WeatherForecast
{
public DateOnly Date { get; set; }
public int TemperatureC { get; set; }
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
public string? Summary { get; set; }
}
+8 -2
View File
@@ -5,7 +5,13 @@
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Warning"
} }
}, },
"TelegramBot": { "ConnectionStrings": {
"BotToken": "" "Postgres": "Host=localhost;Port=5432;Database=gymlogai;Username=postgres;Password=postgres"
},
"OpenRouter": {
"BaseUrl": "https://openrouter.ai/api/v1/",
"ApiKey": "",
"ChatModel": "openai/gpt-4.1-mini",
"EmbeddingModel": "text-embedding-3-small"
} }
} }
+8 -2
View File
@@ -5,8 +5,14 @@
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Warning"
} }
}, },
"TelegramBot": { "ConnectionStrings": {
"BotToken": "" "Postgres": "Host=localhost;Port=5432;Database=gymlogai;Username=postgres;Password=postgres"
},
"OpenRouter": {
"BaseUrl": "https://openrouter.ai/api/v1/",
"ApiKey": "",
"ChatModel": "openai/gpt-4.1-mini",
"EmbeddingModel": "text-embedding-3-small"
}, },
"AllowedHosts": "*" "AllowedHosts": "*"
} }
@@ -0,0 +1,7 @@
namespace GymLogAI.Application.DTOs;
public sealed record ParsedExerciseDto(
string Name,
int Order,
string? Notes,
IReadOnlyCollection<ParsedExerciseSetDto> Sets);
@@ -0,0 +1,9 @@
namespace GymLogAI.Application.DTOs;
public sealed record ParsedExerciseSetDto(
int Order,
int? Repetitions,
decimal? WeightKg,
int? DurationSeconds,
decimal? DistanceMeters,
string? Notes);
@@ -0,0 +1,7 @@
namespace GymLogAI.Application.DTOs;
public sealed record ParsedWorkoutDto(
DateTime PerformedAtUtc,
string? Title,
string? Notes,
IReadOnlyCollection<ParsedExerciseDto> Exercises);
@@ -0,0 +1,8 @@
using GymLogAI.Core.Enums;
namespace GymLogAI.Application.DTOs;
public sealed record WorkoutRecommendationContext(
Guid UserId,
GoalType GoalType,
string? AdditionalContext);
@@ -0,0 +1,7 @@
namespace GymLogAI.Application.DTOs;
public sealed record WorkoutRecommendationDto(
string Title,
string Summary,
IReadOnlyCollection<string> Exercises,
string RecoveryAdvice);
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
@@ -7,7 +7,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" /> <ProjectReference Include="..\GymLogAI.Core\GymLogAI.Core.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -0,0 +1,66 @@
using GymLogAI.Application.Interfaces;
using GymLogAI.Core.Abstractions;
using GymLogAI.Core.Entities;
using GymLogAI.Core.Enums;
namespace GymLogAI.Application.Handlers;
public sealed class ImportTelegramMessageHandler(
IUserRepository userRepository,
ITelegramMessageRepository telegramMessageRepository,
IClock clock,
IIdGenerator idGenerator)
{
public async Task<Guid> HandleAsync(ImportTelegramMessageCommand command, CancellationToken cancellationToken)
{
var user = await userRepository.GetByIdAsync(command.UserId, cancellationToken);
if (user is null)
{
user = new User
{
Id = command.UserId,
TelegramUserId = command.TelegramUserId,
Username = command.Username,
FirstName = command.FirstName,
LastName = command.LastName,
CreatedAtUtc = clock.UtcNow
};
await userRepository.AddAsync(user, cancellationToken);
}
else
{
user.TelegramUserId = command.TelegramUserId;
user.Username = command.Username ?? user.Username;
user.FirstName = command.FirstName ?? user.FirstName;
user.LastName = command.LastName ?? user.LastName;
await userRepository.UpdateAsync(user, cancellationToken);
}
var message = new TelegramMessage
{
Id = idGenerator.New(),
UserId = user.Id,
TelegramChatId = command.TelegramChatId,
TelegramMessageId = command.TelegramMessageId,
Text = command.Text,
SentAtUtc = command.SentAtUtc,
ImportedAtUtc = clock.UtcNow,
ParsingStatus = ParsingStatus.Pending
};
await telegramMessageRepository.AddAsync(message, cancellationToken);
return message.Id;
}
}
public sealed record ImportTelegramMessageCommand(
Guid UserId,
long TelegramUserId,
long TelegramChatId,
int TelegramMessageId,
string Text,
DateTime SentAtUtc,
string? Username,
string? FirstName,
string? LastName);
@@ -0,0 +1,108 @@
using GymLogAI.Application.Interfaces;
using GymLogAI.Core.Abstractions;
using GymLogAI.Core.Entities;
using GymLogAI.Core.Enums;
namespace GymLogAI.Application.Handlers;
public sealed class ParseTelegramMessageHandler(
ITelegramMessageRepository telegramMessageRepository,
IWorkoutRepository workoutRepository,
IExerciseRepository exerciseRepository,
IWorkoutParser workoutParser,
IEmbeddingService embeddingService,
IClock clock,
IIdGenerator idGenerator)
{
public async Task<bool> HandleAsync(Guid telegramMessageId, CancellationToken cancellationToken)
{
var message = await telegramMessageRepository.GetByIdAsync(telegramMessageId, cancellationToken);
if (message is null)
{
return false;
}
message.ParsingStatus = ParsingStatus.Processing;
message.ParsingError = null;
await telegramMessageRepository.UpdateAsync(message, cancellationToken);
try
{
var parsedWorkout = await workoutParser.ParseAsync(message.Text, cancellationToken);
if (parsedWorkout is null || parsedWorkout.Exercises.Count == 0)
{
message.ParsingStatus = ParsingStatus.Failed;
message.ParsingError = "Unable to parse workout from message.";
message.ParsedAtUtc = clock.UtcNow;
await telegramMessageRepository.UpdateAsync(message, cancellationToken);
return false;
}
var workout = new Workout
{
Id = idGenerator.New(),
UserId = message.UserId,
SourceTelegramMessageId = message.Id,
PerformedAtUtc = parsedWorkout.PerformedAtUtc,
Title = parsedWorkout.Title,
Notes = parsedWorkout.Notes,
SourceType = WorkoutSourceType.Telegram,
CreatedAtUtc = clock.UtcNow
};
foreach (var parsedExercise in parsedWorkout.Exercises.OrderBy(x => x.Order))
{
var exercise = await exerciseRepository.GetByNameAsync(parsedExercise.Name, cancellationToken)
?? await exerciseRepository.AddAsync(new Exercise
{
Id = idGenerator.New(),
Name = parsedExercise.Name.Trim(),
CreatedAtUtc = clock.UtcNow,
Embedding = await embeddingService.GenerateEmbeddingAsync(parsedExercise.Name, cancellationToken)
}, cancellationToken);
var workoutExercise = new WorkoutExercise
{
Id = idGenerator.New(),
WorkoutId = workout.Id,
ExerciseId = exercise.Id,
Order = parsedExercise.Order,
Notes = parsedExercise.Notes
};
foreach (var parsedSet in parsedExercise.Sets.OrderBy(x => x.Order))
{
workoutExercise.Sets.Add(new ExerciseSet
{
Id = idGenerator.New(),
WorkoutExerciseId = workoutExercise.Id,
Order = parsedSet.Order,
Repetitions = parsedSet.Repetitions,
WeightKg = parsedSet.WeightKg,
DurationSeconds = parsedSet.DurationSeconds,
DistanceMeters = parsedSet.DistanceMeters,
Notes = parsedSet.Notes
});
}
workout.WorkoutExercises.Add(workoutExercise);
}
await workoutRepository.AddAsync(workout, cancellationToken);
message.WorkoutId = workout.Id;
message.ParsingStatus = ParsingStatus.Parsed;
message.ParsedAtUtc = clock.UtcNow;
await telegramMessageRepository.UpdateAsync(message, cancellationToken);
return true;
}
catch (Exception ex)
{
message.ParsingStatus = ParsingStatus.Failed;
message.ParsingError = ex.Message;
message.ParsedAtUtc = clock.UtcNow;
await telegramMessageRepository.UpdateAsync(message, cancellationToken);
return false;
}
}
}
@@ -0,0 +1,26 @@
using GymLogAI.Application.DTOs;
using GymLogAI.Application.Interfaces;
using GymLogAI.Core.Enums;
namespace GymLogAI.Application.Handlers;
public sealed class RecommendWorkoutHandler(
IWorkoutRepository workoutRepository,
IWorkoutRecommendationGenerator workoutRecommendationGenerator)
{
public async Task<WorkoutRecommendationDto> HandleAsync(
RecommendWorkoutCommand command,
CancellationToken cancellationToken)
{
var history = await workoutRepository.GetHistoryAsync(command.UserId, cancellationToken);
var context = new WorkoutRecommendationContext(
command.UserId,
command.GoalType,
command.AdditionalContext);
return await workoutRecommendationGenerator.GenerateAsync(context, history, cancellationToken);
}
}
public sealed record RecommendWorkoutCommand(Guid UserId, GoalType GoalType, string? AdditionalContext);
@@ -0,0 +1,6 @@
namespace GymLogAI.Application.Interfaces;
public interface IEmbeddingService
{
Task<float[]> GenerateEmbeddingAsync(string input, CancellationToken cancellationToken);
}
@@ -0,0 +1,10 @@
using GymLogAI.Core.Entities;
namespace GymLogAI.Application.Interfaces;
public interface IExerciseRepository
{
Task<Exercise?> GetByNameAsync(string name, CancellationToken cancellationToken);
Task<Exercise> AddAsync(Exercise exercise, CancellationToken cancellationToken);
Task<Exercise> UpdateAsync(Exercise exercise, CancellationToken cancellationToken);
}
@@ -0,0 +1,11 @@
using GymLogAI.Core.Entities;
namespace GymLogAI.Application.Interfaces;
public interface ITelegramMessageRepository
{
Task<TelegramMessage> AddAsync(TelegramMessage message, CancellationToken cancellationToken);
Task<TelegramMessage?> GetByIdAsync(Guid id, CancellationToken cancellationToken);
Task<IReadOnlyCollection<TelegramMessage>> GetPendingAsync(int take, CancellationToken cancellationToken);
Task<TelegramMessage> UpdateAsync(TelegramMessage message, CancellationToken cancellationToken);
}
@@ -0,0 +1,11 @@
using GymLogAI.Core.Entities;
namespace GymLogAI.Application.Interfaces;
public interface IUserRepository
{
Task<User?> GetByIdAsync(Guid id, CancellationToken cancellationToken);
Task<User?> GetByTelegramUserIdAsync(long telegramUserId, CancellationToken cancellationToken);
Task<User> AddAsync(User user, CancellationToken cancellationToken);
Task<User> UpdateAsync(User user, CancellationToken cancellationToken);
}
@@ -0,0 +1,8 @@
using GymLogAI.Application.DTOs;
namespace GymLogAI.Application.Interfaces;
public interface IWorkoutParser
{
Task<ParsedWorkoutDto?> ParseAsync(string text, CancellationToken cancellationToken);
}
@@ -0,0 +1,12 @@
using GymLogAI.Application.DTOs;
using GymLogAI.Core.Entities;
namespace GymLogAI.Application.Interfaces;
public interface IWorkoutRecommendationGenerator
{
Task<WorkoutRecommendationDto> GenerateAsync(
WorkoutRecommendationContext context,
IReadOnlyCollection<Workout> workoutHistory,
CancellationToken cancellationToken);
}
@@ -0,0 +1,9 @@
using GymLogAI.Core.Entities;
namespace GymLogAI.Application.Interfaces;
public interface IWorkoutRepository
{
Task<Workout> AddAsync(Workout workout, CancellationToken cancellationToken);
Task<IReadOnlyCollection<Workout>> GetHistoryAsync(Guid userId, CancellationToken cancellationToken);
}
+6
View File
@@ -0,0 +1,6 @@
namespace GymLogAI.Core.Abstractions;
public interface IClock
{
DateTime UtcNow { get; }
}
@@ -0,0 +1,6 @@
namespace GymLogAI.Core.Abstractions;
public interface IIdGenerator
{
Guid New();
}
+12
View File
@@ -0,0 +1,12 @@
namespace GymLogAI.Core.Entities;
public class Exercise
{
public Guid Id { get; set; }
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
public float[]? Embedding { get; set; }
public DateTime CreatedAtUtc { get; set; }
public List<WorkoutExercise> WorkoutExercises { get; set; } = [];
}
+15
View File
@@ -0,0 +1,15 @@
namespace GymLogAI.Core.Entities;
public class ExerciseSet
{
public Guid Id { get; set; }
public Guid WorkoutExerciseId { get; set; }
public int Order { get; set; }
public decimal? WeightKg { get; set; }
public int? Repetitions { get; set; }
public int? DurationSeconds { get; set; }
public decimal? DistanceMeters { get; set; }
public string? Notes { get; set; }
public WorkoutExercise? WorkoutExercise { get; set; }
}
+21
View File
@@ -0,0 +1,21 @@
using GymLogAI.Core.Enums;
namespace GymLogAI.Core.Entities;
public class TelegramMessage
{
public Guid Id { get; set; }
public Guid UserId { get; set; }
public long TelegramChatId { get; set; }
public int TelegramMessageId { get; set; }
public string Text { get; set; } = string.Empty;
public DateTime SentAtUtc { get; set; }
public DateTime ImportedAtUtc { get; set; }
public ParsingStatus ParsingStatus { get; set; }
public string? ParsingError { get; set; }
public Guid? WorkoutId { get; set; }
public DateTime? ParsedAtUtc { get; set; }
public User? User { get; set; }
public Workout? Workout { get; set; }
}
+14
View File
@@ -0,0 +1,14 @@
namespace GymLogAI.Core.Entities;
public class User
{
public Guid Id { get; set; }
public long TelegramUserId { get; set; }
public string? Username { get; set; }
public string? FirstName { get; set; }
public string? LastName { get; set; }
public DateTime CreatedAtUtc { get; set; }
public List<TelegramMessage> TelegramMessages { get; set; } = [];
public List<Workout> Workouts { get; set; } = [];
}
+19
View File
@@ -0,0 +1,19 @@
using GymLogAI.Core.Enums;
namespace GymLogAI.Core.Entities;
public class Workout
{
public Guid Id { get; set; }
public Guid UserId { get; set; }
public Guid? SourceTelegramMessageId { get; set; }
public DateTime PerformedAtUtc { get; set; }
public string? Title { get; set; }
public string? Notes { get; set; }
public WorkoutSourceType SourceType { get; set; }
public DateTime CreatedAtUtc { get; set; }
public User? User { get; set; }
public TelegramMessage? SourceTelegramMessage { get; set; }
public List<WorkoutExercise> WorkoutExercises { get; set; } = [];
}
+14
View File
@@ -0,0 +1,14 @@
namespace GymLogAI.Core.Entities;
public class WorkoutExercise
{
public Guid Id { get; set; }
public Guid WorkoutId { get; set; }
public Guid ExerciseId { get; set; }
public int Order { get; set; }
public string? Notes { get; set; }
public Workout? Workout { get; set; }
public Exercise? Exercise { get; set; }
public List<ExerciseSet> Sets { get; set; } = [];
}
+10
View File
@@ -0,0 +1,10 @@
namespace GymLogAI.Core.Enums;
public enum GoalType
{
Maintain = 0,
Strength = 1,
Hypertrophy = 2,
Endurance = 3,
WeightLoss = 4
}
+9
View File
@@ -0,0 +1,9 @@
namespace GymLogAI.Core.Enums;
public enum ParsingStatus
{
Pending = 0,
Processing = 1,
Parsed = 2,
Failed = 3
}
+8
View File
@@ -0,0 +1,8 @@
namespace GymLogAI.Core.Enums;
public enum WorkoutSourceType
{
Manual = 0,
Telegram = 1,
Imported = 2
}
+9
View File
@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
+19
View File
@@ -0,0 +1,19 @@
using GymLogAI.Core.Entities;
using Microsoft.EntityFrameworkCore;
namespace GymLogAI.Persistence;
public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{
public DbSet<User> Users => Set<User>();
public DbSet<TelegramMessage> TelegramMessages => Set<TelegramMessage>();
public DbSet<Workout> Workouts => Set<Workout>();
public DbSet<Exercise> Exercises => Set<Exercise>();
public DbSet<WorkoutExercise> WorkoutExercises => Set<WorkoutExercise>();
public DbSet<ExerciseSet> ExerciseSets => Set<ExerciseSet>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
}
}
@@ -0,0 +1,20 @@
using GymLogAI.Core.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace GymLogAI.Persistence.Configurations;
public sealed class ExerciseConfiguration : IEntityTypeConfiguration<Exercise>
{
public void Configure(EntityTypeBuilder<Exercise> builder)
{
builder.ToTable("exercises");
builder.HasKey(x => x.Id);
builder.Property(x => x.Id).ValueGeneratedNever();
builder.Property(x => x.Name).HasMaxLength(256).IsRequired();
builder.Property(x => x.Description).HasMaxLength(2000);
builder.Property(x => x.Embedding).HasColumnType("real[]");
builder.HasIndex(x => x.Name).IsUnique();
}
}
@@ -0,0 +1,20 @@
using GymLogAI.Core.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace GymLogAI.Persistence.Configurations;
public sealed class ExerciseSetConfiguration : IEntityTypeConfiguration<ExerciseSet>
{
public void Configure(EntityTypeBuilder<ExerciseSet> builder)
{
builder.ToTable("exercise_sets");
builder.HasKey(x => x.Id);
builder.Property(x => x.Id).ValueGeneratedNever();
builder.Property(x => x.WeightKg).HasPrecision(8, 2);
builder.Property(x => x.DistanceMeters).HasPrecision(10, 2);
builder.Property(x => x.Notes).HasMaxLength(1000);
builder.HasIndex(x => new { x.WorkoutExerciseId, x.Order }).IsUnique();
}
}
@@ -0,0 +1,31 @@
using GymLogAI.Core.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace GymLogAI.Persistence.Configurations;
public sealed class TelegramMessageConfiguration : IEntityTypeConfiguration<TelegramMessage>
{
public void Configure(EntityTypeBuilder<TelegramMessage> builder)
{
builder.ToTable("telegram_messages");
builder.HasKey(x => x.Id);
builder.Property(x => x.Id).ValueGeneratedNever();
builder.Property(x => x.Text).HasMaxLength(4000).IsRequired();
builder.Property(x => x.ParsingError).HasMaxLength(2000);
builder.HasOne(x => x.User)
.WithMany(x => x.TelegramMessages)
.HasForeignKey(x => x.UserId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne(x => x.Workout)
.WithOne(x => x.SourceTelegramMessage)
.HasForeignKey<Workout>(x => x.SourceTelegramMessageId)
.IsRequired(false)
.OnDelete(DeleteBehavior.SetNull);
builder.HasIndex(x => new { x.UserId, x.ParsingStatus });
builder.HasIndex(x => new { x.TelegramChatId, x.TelegramMessageId }).IsUnique();
}
}
@@ -0,0 +1,22 @@
using GymLogAI.Core.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace GymLogAI.Persistence.Configurations;
public sealed class UserConfiguration : IEntityTypeConfiguration<User>
{
public void Configure(EntityTypeBuilder<User> builder)
{
builder.ToTable("users");
builder.HasKey(x => x.Id);
builder.Property(x => x.Id).ValueGeneratedNever();
builder.Property(x => x.TelegramUserId).IsRequired();
builder.Property(x => x.Username).HasMaxLength(128);
builder.Property(x => x.FirstName).HasMaxLength(128);
builder.Property(x => x.LastName).HasMaxLength(128);
builder.Property(x => x.CreatedAtUtc).IsRequired();
builder.HasIndex(x => x.TelegramUserId).IsUnique();
}
}
@@ -0,0 +1,29 @@
using GymLogAI.Core.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace GymLogAI.Persistence.Configurations;
public sealed class WorkoutConfiguration : IEntityTypeConfiguration<Workout>
{
public void Configure(EntityTypeBuilder<Workout> builder)
{
builder.ToTable("workouts");
builder.HasKey(x => x.Id);
builder.Property(x => x.Id).ValueGeneratedNever();
builder.Property(x => x.Title).HasMaxLength(256);
builder.Property(x => x.Notes).HasMaxLength(4000);
builder.HasOne(x => x.User)
.WithMany(x => x.Workouts)
.HasForeignKey(x => x.UserId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(x => x.WorkoutExercises)
.WithOne(x => x.Workout)
.HasForeignKey(x => x.WorkoutId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasIndex(x => new { x.UserId, x.PerformedAtUtc });
}
}
@@ -0,0 +1,28 @@
using GymLogAI.Core.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace GymLogAI.Persistence.Configurations;
public sealed class WorkoutExerciseConfiguration : IEntityTypeConfiguration<WorkoutExercise>
{
public void Configure(EntityTypeBuilder<WorkoutExercise> builder)
{
builder.ToTable("workout_exercises");
builder.HasKey(x => x.Id);
builder.Property(x => x.Id).ValueGeneratedNever();
builder.Property(x => x.Notes).HasMaxLength(2000);
builder.HasOne(x => x.Exercise)
.WithMany(x => x.WorkoutExercises)
.HasForeignKey(x => x.ExerciseId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasMany(x => x.Sets)
.WithOne(x => x.WorkoutExercise)
.HasForeignKey(x => x.WorkoutExerciseId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasIndex(x => new { x.WorkoutId, x.Order }).IsUnique();
}
}
@@ -0,0 +1,29 @@
using GymLogAI.Application.Interfaces;
using GymLogAI.Core.Abstractions;
using GymLogAI.Persistence.Infrastructure;
using GymLogAI.Persistence.Repositories;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace GymLogAI.Persistence;
public static class DependencyInjection
{
public static IServiceCollection AddPersistence(this IServiceCollection services, IConfiguration configuration)
{
var connectionString = configuration.GetConnectionString("Postgres")
?? "Host=localhost;Port=5432;Database=gymlogai;Username=postgres;Password=postgres";
services.AddDbContext<AppDbContext>(options => options.UseNpgsql(connectionString));
services.AddScoped<IUserRepository, UserRepository>();
services.AddScoped<ITelegramMessageRepository, TelegramMessageRepository>();
services.AddScoped<IWorkoutRepository, WorkoutRepository>();
services.AddScoped<IExerciseRepository, ExerciseRepository>();
services.AddSingleton<IClock, SystemClock>();
services.AddSingleton<IIdGenerator, GuidIdGenerator>();
return services;
}
}
@@ -0,0 +1,15 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace GymLogAI.Persistence;
public sealed class DesignTimeAppDbContextFactory : IDesignTimeDbContextFactory<AppDbContext>
{
public AppDbContext CreateDbContext(string[] args)
{
var optionsBuilder = new DbContextOptionsBuilder<AppDbContext>();
optionsBuilder.UseNpgsql("Host=localhost;Port=5432;Database=gymlogai;Username=postgres;Password=postgres");
return new AppDbContext(optionsBuilder.Options);
}
}
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\GymLogAI.Application\GymLogAI.Application.csproj" />
<ProjectReference Include="..\GymLogAI.Core\GymLogAI.Core.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,8 @@
using GymLogAI.Core.Abstractions;
namespace GymLogAI.Persistence.Infrastructure;
public sealed class GuidIdGenerator : IIdGenerator
{
public Guid New() => Guid.NewGuid();
}
@@ -0,0 +1,8 @@
using GymLogAI.Core.Abstractions;
namespace GymLogAI.Persistence.Infrastructure;
public sealed class SystemClock : IClock
{
public DateTime UtcNow => DateTime.UtcNow;
}
@@ -0,0 +1,326 @@
// <auto-generated />
using System;
using GymLogAI.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace GymLogAI.Persistence.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260419100551_InitialCreate")]
partial class InitialCreate
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("GymLogAI.Core.Entities.Exercise", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.PrimitiveCollection<float[]>("Embedding")
.HasColumnType("real[]");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("exercises", (string)null);
});
modelBuilder.Entity("GymLogAI.Core.Entities.ExerciseSet", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<decimal?>("DistanceMeters")
.HasPrecision(10, 2)
.HasColumnType("numeric(10,2)");
b.Property<int?>("DurationSeconds")
.HasColumnType("integer");
b.Property<string>("Notes")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<int>("Order")
.HasColumnType("integer");
b.Property<int?>("Repetitions")
.HasColumnType("integer");
b.Property<decimal?>("WeightKg")
.HasPrecision(8, 2)
.HasColumnType("numeric(8,2)");
b.Property<Guid>("WorkoutExerciseId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("WorkoutExerciseId", "Order")
.IsUnique();
b.ToTable("exercise_sets", (string)null);
});
modelBuilder.Entity("GymLogAI.Core.Entities.TelegramMessage", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<DateTime>("ImportedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("ParsedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("ParsingError")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<int>("ParsingStatus")
.HasColumnType("integer");
b.Property<DateTime>("SentAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<long>("TelegramChatId")
.HasColumnType("bigint");
b.Property<int>("TelegramMessageId")
.HasColumnType("integer");
b.Property<string>("Text")
.IsRequired()
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<Guid?>("WorkoutId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TelegramChatId", "TelegramMessageId")
.IsUnique();
b.HasIndex("UserId", "ParsingStatus");
b.ToTable("telegram_messages", (string)null);
});
modelBuilder.Entity("GymLogAI.Core.Entities.User", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("FirstName")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("LastName")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<long>("TelegramUserId")
.HasColumnType("bigint");
b.Property<string>("Username")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.HasKey("Id");
b.HasIndex("TelegramUserId")
.IsUnique();
b.ToTable("users", (string)null);
});
modelBuilder.Entity("GymLogAI.Core.Entities.Workout", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("Notes")
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
b.Property<DateTime>("PerformedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("SourceTelegramMessageId")
.HasColumnType("uuid");
b.Property<int>("SourceType")
.HasColumnType("integer");
b.Property<string>("Title")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("SourceTelegramMessageId")
.IsUnique();
b.HasIndex("UserId", "PerformedAtUtc");
b.ToTable("workouts", (string)null);
});
modelBuilder.Entity("GymLogAI.Core.Entities.WorkoutExercise", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<Guid>("ExerciseId")
.HasColumnType("uuid");
b.Property<string>("Notes")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<int>("Order")
.HasColumnType("integer");
b.Property<Guid>("WorkoutId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ExerciseId");
b.HasIndex("WorkoutId", "Order")
.IsUnique();
b.ToTable("workout_exercises", (string)null);
});
modelBuilder.Entity("GymLogAI.Core.Entities.ExerciseSet", b =>
{
b.HasOne("GymLogAI.Core.Entities.WorkoutExercise", "WorkoutExercise")
.WithMany("Sets")
.HasForeignKey("WorkoutExerciseId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("WorkoutExercise");
});
modelBuilder.Entity("GymLogAI.Core.Entities.TelegramMessage", b =>
{
b.HasOne("GymLogAI.Core.Entities.User", "User")
.WithMany("TelegramMessages")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("GymLogAI.Core.Entities.Workout", b =>
{
b.HasOne("GymLogAI.Core.Entities.TelegramMessage", "SourceTelegramMessage")
.WithOne("Workout")
.HasForeignKey("GymLogAI.Core.Entities.Workout", "SourceTelegramMessageId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("GymLogAI.Core.Entities.User", "User")
.WithMany("Workouts")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("SourceTelegramMessage");
b.Navigation("User");
});
modelBuilder.Entity("GymLogAI.Core.Entities.WorkoutExercise", b =>
{
b.HasOne("GymLogAI.Core.Entities.Exercise", "Exercise")
.WithMany("WorkoutExercises")
.HasForeignKey("ExerciseId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("GymLogAI.Core.Entities.Workout", "Workout")
.WithMany("WorkoutExercises")
.HasForeignKey("WorkoutId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Exercise");
b.Navigation("Workout");
});
modelBuilder.Entity("GymLogAI.Core.Entities.Exercise", b =>
{
b.Navigation("WorkoutExercises");
});
modelBuilder.Entity("GymLogAI.Core.Entities.TelegramMessage", b =>
{
b.Navigation("Workout");
});
modelBuilder.Entity("GymLogAI.Core.Entities.User", b =>
{
b.Navigation("TelegramMessages");
b.Navigation("Workouts");
});
modelBuilder.Entity("GymLogAI.Core.Entities.Workout", b =>
{
b.Navigation("WorkoutExercises");
});
modelBuilder.Entity("GymLogAI.Core.Entities.WorkoutExercise", b =>
{
b.Navigation("Sets");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,227 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GymLogAI.Persistence.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "exercises",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
Description = table.Column<string>(type: "character varying(2000)", maxLength: 2000, nullable: true),
Embedding = table.Column<float[]>(type: "real[]", nullable: true),
CreatedAtUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_exercises", x => x.Id);
});
migrationBuilder.CreateTable(
name: "users",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
TelegramUserId = table.Column<long>(type: "bigint", nullable: false),
Username = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
FirstName = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
LastName = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
CreatedAtUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_users", x => x.Id);
});
migrationBuilder.CreateTable(
name: "telegram_messages",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
TelegramChatId = table.Column<long>(type: "bigint", nullable: false),
TelegramMessageId = table.Column<int>(type: "integer", nullable: false),
Text = table.Column<string>(type: "character varying(4000)", maxLength: 4000, nullable: false),
SentAtUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
ImportedAtUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
ParsingStatus = table.Column<int>(type: "integer", nullable: false),
ParsingError = table.Column<string>(type: "character varying(2000)", maxLength: 2000, nullable: true),
WorkoutId = table.Column<Guid>(type: "uuid", nullable: true),
ParsedAtUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_telegram_messages", x => x.Id);
table.ForeignKey(
name: "FK_telegram_messages_users_UserId",
column: x => x.UserId,
principalTable: "users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "workouts",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
SourceTelegramMessageId = table.Column<Guid>(type: "uuid", nullable: true),
PerformedAtUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
Title = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
Notes = table.Column<string>(type: "character varying(4000)", maxLength: 4000, nullable: true),
SourceType = table.Column<int>(type: "integer", nullable: false),
CreatedAtUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_workouts", x => x.Id);
table.ForeignKey(
name: "FK_workouts_telegram_messages_SourceTelegramMessageId",
column: x => x.SourceTelegramMessageId,
principalTable: "telegram_messages",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_workouts_users_UserId",
column: x => x.UserId,
principalTable: "users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "workout_exercises",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
WorkoutId = table.Column<Guid>(type: "uuid", nullable: false),
ExerciseId = table.Column<Guid>(type: "uuid", nullable: false),
Order = table.Column<int>(type: "integer", nullable: false),
Notes = table.Column<string>(type: "character varying(2000)", maxLength: 2000, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_workout_exercises", x => x.Id);
table.ForeignKey(
name: "FK_workout_exercises_exercises_ExerciseId",
column: x => x.ExerciseId,
principalTable: "exercises",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_workout_exercises_workouts_WorkoutId",
column: x => x.WorkoutId,
principalTable: "workouts",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "exercise_sets",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
WorkoutExerciseId = table.Column<Guid>(type: "uuid", nullable: false),
Order = table.Column<int>(type: "integer", nullable: false),
WeightKg = table.Column<decimal>(type: "numeric(8,2)", precision: 8, scale: 2, nullable: true),
Repetitions = table.Column<int>(type: "integer", nullable: true),
DurationSeconds = table.Column<int>(type: "integer", nullable: true),
DistanceMeters = table.Column<decimal>(type: "numeric(10,2)", precision: 10, scale: 2, nullable: true),
Notes = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_exercise_sets", x => x.Id);
table.ForeignKey(
name: "FK_exercise_sets_workout_exercises_WorkoutExerciseId",
column: x => x.WorkoutExerciseId,
principalTable: "workout_exercises",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_exercise_sets_WorkoutExerciseId_Order",
table: "exercise_sets",
columns: new[] { "WorkoutExerciseId", "Order" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_exercises_Name",
table: "exercises",
column: "Name",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_telegram_messages_TelegramChatId_TelegramMessageId",
table: "telegram_messages",
columns: new[] { "TelegramChatId", "TelegramMessageId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_telegram_messages_UserId_ParsingStatus",
table: "telegram_messages",
columns: new[] { "UserId", "ParsingStatus" });
migrationBuilder.CreateIndex(
name: "IX_users_TelegramUserId",
table: "users",
column: "TelegramUserId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_workout_exercises_ExerciseId",
table: "workout_exercises",
column: "ExerciseId");
migrationBuilder.CreateIndex(
name: "IX_workout_exercises_WorkoutId_Order",
table: "workout_exercises",
columns: new[] { "WorkoutId", "Order" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_workouts_SourceTelegramMessageId",
table: "workouts",
column: "SourceTelegramMessageId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_workouts_UserId_PerformedAtUtc",
table: "workouts",
columns: new[] { "UserId", "PerformedAtUtc" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "exercise_sets");
migrationBuilder.DropTable(
name: "workout_exercises");
migrationBuilder.DropTable(
name: "exercises");
migrationBuilder.DropTable(
name: "workouts");
migrationBuilder.DropTable(
name: "telegram_messages");
migrationBuilder.DropTable(
name: "users");
}
}
}
@@ -0,0 +1,323 @@
// <auto-generated />
using System;
using GymLogAI.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace GymLogAI.Persistence.Migrations
{
[DbContext(typeof(AppDbContext))]
partial class AppDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("GymLogAI.Core.Entities.Exercise", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.PrimitiveCollection<float[]>("Embedding")
.HasColumnType("real[]");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("exercises", (string)null);
});
modelBuilder.Entity("GymLogAI.Core.Entities.ExerciseSet", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<decimal?>("DistanceMeters")
.HasPrecision(10, 2)
.HasColumnType("numeric(10,2)");
b.Property<int?>("DurationSeconds")
.HasColumnType("integer");
b.Property<string>("Notes")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<int>("Order")
.HasColumnType("integer");
b.Property<int?>("Repetitions")
.HasColumnType("integer");
b.Property<decimal?>("WeightKg")
.HasPrecision(8, 2)
.HasColumnType("numeric(8,2)");
b.Property<Guid>("WorkoutExerciseId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("WorkoutExerciseId", "Order")
.IsUnique();
b.ToTable("exercise_sets", (string)null);
});
modelBuilder.Entity("GymLogAI.Core.Entities.TelegramMessage", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<DateTime>("ImportedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("ParsedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("ParsingError")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<int>("ParsingStatus")
.HasColumnType("integer");
b.Property<DateTime>("SentAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<long>("TelegramChatId")
.HasColumnType("bigint");
b.Property<int>("TelegramMessageId")
.HasColumnType("integer");
b.Property<string>("Text")
.IsRequired()
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<Guid?>("WorkoutId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TelegramChatId", "TelegramMessageId")
.IsUnique();
b.HasIndex("UserId", "ParsingStatus");
b.ToTable("telegram_messages", (string)null);
});
modelBuilder.Entity("GymLogAI.Core.Entities.User", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("FirstName")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("LastName")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<long>("TelegramUserId")
.HasColumnType("bigint");
b.Property<string>("Username")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.HasKey("Id");
b.HasIndex("TelegramUserId")
.IsUnique();
b.ToTable("users", (string)null);
});
modelBuilder.Entity("GymLogAI.Core.Entities.Workout", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("Notes")
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
b.Property<DateTime>("PerformedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("SourceTelegramMessageId")
.HasColumnType("uuid");
b.Property<int>("SourceType")
.HasColumnType("integer");
b.Property<string>("Title")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("SourceTelegramMessageId")
.IsUnique();
b.HasIndex("UserId", "PerformedAtUtc");
b.ToTable("workouts", (string)null);
});
modelBuilder.Entity("GymLogAI.Core.Entities.WorkoutExercise", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<Guid>("ExerciseId")
.HasColumnType("uuid");
b.Property<string>("Notes")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<int>("Order")
.HasColumnType("integer");
b.Property<Guid>("WorkoutId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ExerciseId");
b.HasIndex("WorkoutId", "Order")
.IsUnique();
b.ToTable("workout_exercises", (string)null);
});
modelBuilder.Entity("GymLogAI.Core.Entities.ExerciseSet", b =>
{
b.HasOne("GymLogAI.Core.Entities.WorkoutExercise", "WorkoutExercise")
.WithMany("Sets")
.HasForeignKey("WorkoutExerciseId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("WorkoutExercise");
});
modelBuilder.Entity("GymLogAI.Core.Entities.TelegramMessage", b =>
{
b.HasOne("GymLogAI.Core.Entities.User", "User")
.WithMany("TelegramMessages")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("GymLogAI.Core.Entities.Workout", b =>
{
b.HasOne("GymLogAI.Core.Entities.TelegramMessage", "SourceTelegramMessage")
.WithOne("Workout")
.HasForeignKey("GymLogAI.Core.Entities.Workout", "SourceTelegramMessageId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("GymLogAI.Core.Entities.User", "User")
.WithMany("Workouts")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("SourceTelegramMessage");
b.Navigation("User");
});
modelBuilder.Entity("GymLogAI.Core.Entities.WorkoutExercise", b =>
{
b.HasOne("GymLogAI.Core.Entities.Exercise", "Exercise")
.WithMany("WorkoutExercises")
.HasForeignKey("ExerciseId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("GymLogAI.Core.Entities.Workout", "Workout")
.WithMany("WorkoutExercises")
.HasForeignKey("WorkoutId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Exercise");
b.Navigation("Workout");
});
modelBuilder.Entity("GymLogAI.Core.Entities.Exercise", b =>
{
b.Navigation("WorkoutExercises");
});
modelBuilder.Entity("GymLogAI.Core.Entities.TelegramMessage", b =>
{
b.Navigation("Workout");
});
modelBuilder.Entity("GymLogAI.Core.Entities.User", b =>
{
b.Navigation("TelegramMessages");
b.Navigation("Workouts");
});
modelBuilder.Entity("GymLogAI.Core.Entities.Workout", b =>
{
b.Navigation("WorkoutExercises");
});
modelBuilder.Entity("GymLogAI.Core.Entities.WorkoutExercise", b =>
{
b.Navigation("Sets");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,27 @@
using GymLogAI.Application.Interfaces;
using GymLogAI.Core.Entities;
using Microsoft.EntityFrameworkCore;
namespace GymLogAI.Persistence.Repositories;
public sealed class ExerciseRepository(AppDbContext dbContext) : IExerciseRepository
{
public async Task<Exercise?> GetByNameAsync(string name, CancellationToken cancellationToken)
{
return await dbContext.Exercises.SingleOrDefaultAsync(x => x.Name == name, cancellationToken);
}
public async Task<Exercise> AddAsync(Exercise exercise, CancellationToken cancellationToken)
{
dbContext.Exercises.Add(exercise);
await dbContext.SaveChangesAsync(cancellationToken);
return exercise;
}
public async Task<Exercise> UpdateAsync(Exercise exercise, CancellationToken cancellationToken)
{
dbContext.Exercises.Update(exercise);
await dbContext.SaveChangesAsync(cancellationToken);
return exercise;
}
}
@@ -0,0 +1,37 @@
using GymLogAI.Application.Interfaces;
using GymLogAI.Core.Entities;
using GymLogAI.Core.Enums;
using Microsoft.EntityFrameworkCore;
namespace GymLogAI.Persistence.Repositories;
public sealed class TelegramMessageRepository(AppDbContext dbContext) : ITelegramMessageRepository
{
public async Task<TelegramMessage> AddAsync(TelegramMessage message, CancellationToken cancellationToken)
{
dbContext.TelegramMessages.Add(message);
await dbContext.SaveChangesAsync(cancellationToken);
return message;
}
public async Task<TelegramMessage?> GetByIdAsync(Guid id, CancellationToken cancellationToken)
{
return await dbContext.TelegramMessages.SingleOrDefaultAsync(x => x.Id == id, cancellationToken);
}
public async Task<IReadOnlyCollection<TelegramMessage>> GetPendingAsync(int take, CancellationToken cancellationToken)
{
return await dbContext.TelegramMessages
.Where(x => x.ParsingStatus == ParsingStatus.Pending)
.OrderBy(x => x.ImportedAtUtc)
.Take(take)
.ToListAsync(cancellationToken);
}
public async Task<TelegramMessage> UpdateAsync(TelegramMessage message, CancellationToken cancellationToken)
{
dbContext.TelegramMessages.Update(message);
await dbContext.SaveChangesAsync(cancellationToken);
return message;
}
}
@@ -0,0 +1,32 @@
using GymLogAI.Application.Interfaces;
using GymLogAI.Core.Entities;
using Microsoft.EntityFrameworkCore;
namespace GymLogAI.Persistence.Repositories;
public sealed class UserRepository(AppDbContext dbContext) : IUserRepository
{
public async Task<User?> GetByIdAsync(Guid id, CancellationToken cancellationToken)
{
return await dbContext.Users.SingleOrDefaultAsync(x => x.Id == id, cancellationToken);
}
public async Task<User?> GetByTelegramUserIdAsync(long telegramUserId, CancellationToken cancellationToken)
{
return await dbContext.Users.SingleOrDefaultAsync(x => x.TelegramUserId == telegramUserId, cancellationToken);
}
public async Task<User> AddAsync(User user, CancellationToken cancellationToken)
{
dbContext.Users.Add(user);
await dbContext.SaveChangesAsync(cancellationToken);
return user;
}
public async Task<User> UpdateAsync(User user, CancellationToken cancellationToken)
{
dbContext.Users.Update(user);
await dbContext.SaveChangesAsync(cancellationToken);
return user;
}
}
@@ -0,0 +1,28 @@
using GymLogAI.Application.Interfaces;
using GymLogAI.Core.Entities;
using Microsoft.EntityFrameworkCore;
namespace GymLogAI.Persistence.Repositories;
public sealed class WorkoutRepository(AppDbContext dbContext) : IWorkoutRepository
{
public async Task<Workout> AddAsync(Workout workout, CancellationToken cancellationToken)
{
dbContext.Workouts.Add(workout);
await dbContext.SaveChangesAsync(cancellationToken);
return workout;
}
public async Task<IReadOnlyCollection<Workout>> GetHistoryAsync(Guid userId, CancellationToken cancellationToken)
{
return await dbContext.Workouts
.AsNoTracking()
.Include(x => x.WorkoutExercises)
.ThenInclude(x => x.Exercise)
.Include(x => x.WorkoutExercises)
.ThenInclude(x => x.Sets)
.Where(x => x.UserId == userId)
.OrderByDescending(x => x.PerformedAtUtc)
.ToListAsync(cancellationToken);
}
}
+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);
}
}
}
@@ -1,8 +1,9 @@
namespace GymLogAI.TgBot; namespace GymLogAI.TelegramBot;
public sealed record TelegramBotOptions public sealed record TelegramBotOptions
{ {
public const string SectionName = "TelegramBot"; public const string SectionName = "TelegramBot";
public string? BotToken { get; init; } 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"
}
}
@@ -1,19 +0,0 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace GymLogAI.TgBot;
public static class TgBotServiceCollectionExtensions
{
public static IServiceCollection AddTgBot(this IServiceCollection services, IConfiguration configuration)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
services
.AddOptions<TelegramBotOptions>()
.Bind(configuration.GetSection(TelegramBotOptions.SectionName));
return services;
}
}
+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.Worker/GymLogAI.Worker.csproj", "GymLogAI.Worker/"]
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.Worker/GymLogAI.Worker.csproj"
COPY . .
WORKDIR "/src/GymLogAI.Worker"
RUN dotnet publish "./GymLogAI.Worker.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
FROM base AS final
WORKDIR /app
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "GymLogAI.Worker.dll"]
+20
View File
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk.Worker">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>dotnet-GymLogAI.Worker-bf7dded7-f871-4b7d-a513-0a677ed83bd3</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.6" />
</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>
@@ -0,0 +1,40 @@
using GymLogAI.Application.Handlers;
using GymLogAI.Application.Interfaces;
using Microsoft.Extensions.Options;
namespace GymLogAI.Worker;
public sealed class ParsePendingTelegramMessagesJob(
IServiceProvider serviceProvider,
IOptions<WorkerOptions> options,
ILogger<ParsePendingTelegramMessagesJob> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
await using var scope = serviceProvider.CreateAsyncScope();
var repository = scope.ServiceProvider.GetRequiredService<ITelegramMessageRepository>();
var handler = scope.ServiceProvider.GetRequiredService<ParseTelegramMessageHandler>();
var pendingMessages = await repository.GetPendingAsync(options.Value.BatchSize, stoppingToken);
foreach (var pendingMessage in pendingMessages)
{
await handler.HandleAsync(pendingMessage.Id, stoppingToken);
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to process pending telegram messages.");
}
await Task.Delay(TimeSpan.FromSeconds(options.Value.PollingIntervalSeconds), stoppingToken);
}
}
}
+26
View File
@@ -0,0 +1,26 @@
using GymLogAI.AI;
using GymLogAI.Application.Handlers;
using GymLogAI.Persistence;
using GymLogAI.Worker;
using Microsoft.EntityFrameworkCore;
var builder = Host.CreateApplicationBuilder(args);
builder.Services
.AddOptions<WorkerOptions>()
.Bind(builder.Configuration.GetSection(WorkerOptions.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.AddHostedService<ParsePendingTelegramMessagesJob>();
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,12 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"GymLogAI.Worker": {
"commandName": "Project",
"dotnetRunMessages": true,
"environmentVariables": {
"DOTNET_ENVIRONMENT": "Development"
}
}
}
}
+9
View File
@@ -0,0 +1,9 @@
namespace GymLogAI.Worker;
public sealed record WorkerOptions
{
public const string SectionName = "Worker";
public int BatchSize { get; init; } = 20;
public int PollingIntervalSeconds { get; init; } = 10;
}
@@ -0,0 +1,15 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.Hosting.Lifetime": "Information"
}
},
"ConnectionStrings": {
"Postgres": "Host=localhost;Port=5432;Database=gymlogai;Username=postgres;Password=postgres"
},
"Worker": {
"BatchSize": 20,
"PollingIntervalSeconds": 10
}
}
+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"
},
"Worker": {
"BatchSize": 20,
"PollingIntervalSeconds": 10
},
"OpenRouter": {
"BaseUrl": "https://openrouter.ai/api/v1/",
"ApiKey": "",
"ChatModel": "openai/gpt-4.1-mini",
"EmbeddingModel": "text-embedding-3-small"
}
}
+98 -5
View File
@@ -7,21 +7,114 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
compose.yaml = compose.yaml compose.yaml = compose.yaml
EndProjectSection EndProjectSection
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GymLogAI.TgBot", "GymLogAI.TgBot\GymLogAI.TgBot.csproj", "{BAF381C4-F522-4A59-B86D-3B9D3DA3D6AD}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GymLogAI.Core", "GymLogAI.Core\GymLogAI.Core.csproj", "{B8D6F354-28CF-4432-8C09-04CBA164D68D}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GymLogAI.Application", "GymLogAI.Application\GymLogAI.Application.csproj", "{DA9C34CA-7C7E-491F-8B56-AE0DA8BF88FB}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GymLogAI.Persistence", "GymLogAI.Persistence\GymLogAI.Persistence.csproj", "{5BDAED97-9B71-4825-B3BA-F0E0BB9EC011}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GymLogAI.AI", "GymLogAI.AI\GymLogAI.AI.csproj", "{D6691FB8-1EFA-428E-AB7C-562496650A43}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GymLogAI.TelegramBot", "GymLogAI.TelegramBot\GymLogAI.TelegramBot.csproj", "{1C01FFF9-1EE8-471E-8606-A6EA26BFBEEB}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GymLogAI.Worker", "GymLogAI.Worker\GymLogAI.Worker.csproj", "{70B0AFD7-6714-42F1-B754-FF9010687416}"
EndProject EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution GlobalSection(ProjectConfigurationPlatforms) = postSolution
{42540161-196B-43B8-998E-FA9BDD01A74C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {42540161-196B-43B8-998E-FA9BDD01A74C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{42540161-196B-43B8-998E-FA9BDD01A74C}.Debug|Any CPU.Build.0 = Debug|Any CPU {42540161-196B-43B8-998E-FA9BDD01A74C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{42540161-196B-43B8-998E-FA9BDD01A74C}.Debug|x64.ActiveCfg = Debug|Any CPU
{42540161-196B-43B8-998E-FA9BDD01A74C}.Debug|x64.Build.0 = Debug|Any CPU
{42540161-196B-43B8-998E-FA9BDD01A74C}.Debug|x86.ActiveCfg = Debug|Any CPU
{42540161-196B-43B8-998E-FA9BDD01A74C}.Debug|x86.Build.0 = Debug|Any CPU
{42540161-196B-43B8-998E-FA9BDD01A74C}.Release|Any CPU.ActiveCfg = Release|Any CPU {42540161-196B-43B8-998E-FA9BDD01A74C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{42540161-196B-43B8-998E-FA9BDD01A74C}.Release|Any CPU.Build.0 = Release|Any CPU {42540161-196B-43B8-998E-FA9BDD01A74C}.Release|Any CPU.Build.0 = Release|Any CPU
{BAF381C4-F522-4A59-B86D-3B9D3DA3D6AD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {42540161-196B-43B8-998E-FA9BDD01A74C}.Release|x64.ActiveCfg = Release|Any CPU
{BAF381C4-F522-4A59-B86D-3B9D3DA3D6AD}.Debug|Any CPU.Build.0 = Debug|Any CPU {42540161-196B-43B8-998E-FA9BDD01A74C}.Release|x64.Build.0 = Release|Any CPU
{BAF381C4-F522-4A59-B86D-3B9D3DA3D6AD}.Release|Any CPU.ActiveCfg = Release|Any CPU {42540161-196B-43B8-998E-FA9BDD01A74C}.Release|x86.ActiveCfg = Release|Any CPU
{BAF381C4-F522-4A59-B86D-3B9D3DA3D6AD}.Release|Any CPU.Build.0 = Release|Any CPU {42540161-196B-43B8-998E-FA9BDD01A74C}.Release|x86.Build.0 = Release|Any CPU
{B8D6F354-28CF-4432-8C09-04CBA164D68D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B8D6F354-28CF-4432-8C09-04CBA164D68D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B8D6F354-28CF-4432-8C09-04CBA164D68D}.Debug|x64.ActiveCfg = Debug|Any CPU
{B8D6F354-28CF-4432-8C09-04CBA164D68D}.Debug|x64.Build.0 = Debug|Any CPU
{B8D6F354-28CF-4432-8C09-04CBA164D68D}.Debug|x86.ActiveCfg = Debug|Any CPU
{B8D6F354-28CF-4432-8C09-04CBA164D68D}.Debug|x86.Build.0 = Debug|Any CPU
{B8D6F354-28CF-4432-8C09-04CBA164D68D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B8D6F354-28CF-4432-8C09-04CBA164D68D}.Release|Any CPU.Build.0 = Release|Any CPU
{B8D6F354-28CF-4432-8C09-04CBA164D68D}.Release|x64.ActiveCfg = Release|Any CPU
{B8D6F354-28CF-4432-8C09-04CBA164D68D}.Release|x64.Build.0 = Release|Any CPU
{B8D6F354-28CF-4432-8C09-04CBA164D68D}.Release|x86.ActiveCfg = Release|Any CPU
{B8D6F354-28CF-4432-8C09-04CBA164D68D}.Release|x86.Build.0 = Release|Any CPU
{DA9C34CA-7C7E-491F-8B56-AE0DA8BF88FB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{DA9C34CA-7C7E-491F-8B56-AE0DA8BF88FB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{DA9C34CA-7C7E-491F-8B56-AE0DA8BF88FB}.Debug|x64.ActiveCfg = Debug|Any CPU
{DA9C34CA-7C7E-491F-8B56-AE0DA8BF88FB}.Debug|x64.Build.0 = Debug|Any CPU
{DA9C34CA-7C7E-491F-8B56-AE0DA8BF88FB}.Debug|x86.ActiveCfg = Debug|Any CPU
{DA9C34CA-7C7E-491F-8B56-AE0DA8BF88FB}.Debug|x86.Build.0 = Debug|Any CPU
{DA9C34CA-7C7E-491F-8B56-AE0DA8BF88FB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{DA9C34CA-7C7E-491F-8B56-AE0DA8BF88FB}.Release|Any CPU.Build.0 = Release|Any CPU
{DA9C34CA-7C7E-491F-8B56-AE0DA8BF88FB}.Release|x64.ActiveCfg = Release|Any CPU
{DA9C34CA-7C7E-491F-8B56-AE0DA8BF88FB}.Release|x64.Build.0 = Release|Any CPU
{DA9C34CA-7C7E-491F-8B56-AE0DA8BF88FB}.Release|x86.ActiveCfg = Release|Any CPU
{DA9C34CA-7C7E-491F-8B56-AE0DA8BF88FB}.Release|x86.Build.0 = Release|Any CPU
{5BDAED97-9B71-4825-B3BA-F0E0BB9EC011}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5BDAED97-9B71-4825-B3BA-F0E0BB9EC011}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5BDAED97-9B71-4825-B3BA-F0E0BB9EC011}.Debug|x64.ActiveCfg = Debug|Any CPU
{5BDAED97-9B71-4825-B3BA-F0E0BB9EC011}.Debug|x64.Build.0 = Debug|Any CPU
{5BDAED97-9B71-4825-B3BA-F0E0BB9EC011}.Debug|x86.ActiveCfg = Debug|Any CPU
{5BDAED97-9B71-4825-B3BA-F0E0BB9EC011}.Debug|x86.Build.0 = Debug|Any CPU
{5BDAED97-9B71-4825-B3BA-F0E0BB9EC011}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5BDAED97-9B71-4825-B3BA-F0E0BB9EC011}.Release|Any CPU.Build.0 = Release|Any CPU
{5BDAED97-9B71-4825-B3BA-F0E0BB9EC011}.Release|x64.ActiveCfg = Release|Any CPU
{5BDAED97-9B71-4825-B3BA-F0E0BB9EC011}.Release|x64.Build.0 = Release|Any CPU
{5BDAED97-9B71-4825-B3BA-F0E0BB9EC011}.Release|x86.ActiveCfg = Release|Any CPU
{5BDAED97-9B71-4825-B3BA-F0E0BB9EC011}.Release|x86.Build.0 = Release|Any CPU
{D6691FB8-1EFA-428E-AB7C-562496650A43}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D6691FB8-1EFA-428E-AB7C-562496650A43}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D6691FB8-1EFA-428E-AB7C-562496650A43}.Debug|x64.ActiveCfg = Debug|Any CPU
{D6691FB8-1EFA-428E-AB7C-562496650A43}.Debug|x64.Build.0 = Debug|Any CPU
{D6691FB8-1EFA-428E-AB7C-562496650A43}.Debug|x86.ActiveCfg = Debug|Any CPU
{D6691FB8-1EFA-428E-AB7C-562496650A43}.Debug|x86.Build.0 = Debug|Any CPU
{D6691FB8-1EFA-428E-AB7C-562496650A43}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D6691FB8-1EFA-428E-AB7C-562496650A43}.Release|Any CPU.Build.0 = Release|Any CPU
{D6691FB8-1EFA-428E-AB7C-562496650A43}.Release|x64.ActiveCfg = Release|Any CPU
{D6691FB8-1EFA-428E-AB7C-562496650A43}.Release|x64.Build.0 = Release|Any CPU
{D6691FB8-1EFA-428E-AB7C-562496650A43}.Release|x86.ActiveCfg = Release|Any CPU
{D6691FB8-1EFA-428E-AB7C-562496650A43}.Release|x86.Build.0 = Release|Any CPU
{1C01FFF9-1EE8-471E-8606-A6EA26BFBEEB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1C01FFF9-1EE8-471E-8606-A6EA26BFBEEB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1C01FFF9-1EE8-471E-8606-A6EA26BFBEEB}.Debug|x64.ActiveCfg = Debug|Any CPU
{1C01FFF9-1EE8-471E-8606-A6EA26BFBEEB}.Debug|x64.Build.0 = Debug|Any CPU
{1C01FFF9-1EE8-471E-8606-A6EA26BFBEEB}.Debug|x86.ActiveCfg = Debug|Any CPU
{1C01FFF9-1EE8-471E-8606-A6EA26BFBEEB}.Debug|x86.Build.0 = Debug|Any CPU
{1C01FFF9-1EE8-471E-8606-A6EA26BFBEEB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1C01FFF9-1EE8-471E-8606-A6EA26BFBEEB}.Release|Any CPU.Build.0 = Release|Any CPU
{1C01FFF9-1EE8-471E-8606-A6EA26BFBEEB}.Release|x64.ActiveCfg = Release|Any CPU
{1C01FFF9-1EE8-471E-8606-A6EA26BFBEEB}.Release|x64.Build.0 = Release|Any CPU
{1C01FFF9-1EE8-471E-8606-A6EA26BFBEEB}.Release|x86.ActiveCfg = Release|Any CPU
{1C01FFF9-1EE8-471E-8606-A6EA26BFBEEB}.Release|x86.Build.0 = Release|Any CPU
{70B0AFD7-6714-42F1-B754-FF9010687416}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{70B0AFD7-6714-42F1-B754-FF9010687416}.Debug|Any CPU.Build.0 = Debug|Any CPU
{70B0AFD7-6714-42F1-B754-FF9010687416}.Debug|x64.ActiveCfg = Debug|Any CPU
{70B0AFD7-6714-42F1-B754-FF9010687416}.Debug|x64.Build.0 = Debug|Any CPU
{70B0AFD7-6714-42F1-B754-FF9010687416}.Debug|x86.ActiveCfg = Debug|Any CPU
{70B0AFD7-6714-42F1-B754-FF9010687416}.Debug|x86.Build.0 = Debug|Any CPU
{70B0AFD7-6714-42F1-B754-FF9010687416}.Release|Any CPU.ActiveCfg = Release|Any CPU
{70B0AFD7-6714-42F1-B754-FF9010687416}.Release|Any CPU.Build.0 = Release|Any CPU
{70B0AFD7-6714-42F1-B754-FF9010687416}.Release|x64.ActiveCfg = Release|Any CPU
{70B0AFD7-6714-42F1-B754-FF9010687416}.Release|x64.Build.0 = Release|Any CPU
{70B0AFD7-6714-42F1-B754-FF9010687416}.Release|x86.ActiveCfg = Release|Any CPU
{70B0AFD7-6714-42F1-B754-FF9010687416}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection EndGlobalSection
EndGlobal EndGlobal
+51
View File
@@ -0,0 +1,51 @@
# GymLogAI
GymLogAI is a minimal multi-project skeleton for importing workout messages, parsing them into structured training history, and generating workout recommendations.
## Architecture
- `GymLogAI.Core`: domain entities, enums, and primitive abstractions like `IClock` and `IIdGenerator`
- `GymLogAI.Application`: repository contracts, AI contracts, DTOs, and use-case handlers
- `GymLogAI.Persistence`: EF Core `AppDbContext`, PostgreSQL mappings, repositories, infrastructure services, and the first migration
- `GymLogAI.AI`: OpenRouter-ready options and stubbed AI implementations for parsing, recommendations, and embeddings
- `GymLogAI.API`: minimal API composition root with Swagger, import/recommend/history endpoints, and database migration on startup
- `GymLogAI.TelegramBot`: Telegram long-polling host with `/start`, `/today`, and text message import
- `GymLogAI.Worker`: background worker that polls pending telegram messages and parses them into workouts
## Runtime Flow
1. API or Telegram bot imports a raw Telegram message into `telegram_messages` with `Pending` status.
2. Worker polls pending messages and runs `ParseTelegramMessageHandler`.
3. Parsed workouts are stored in `workouts`, `workout_exercises`, and `exercise_sets`.
4. API and Telegram bot can request workout recommendations from the application layer.
## Key Endpoints
- `POST /api/messages/import`
- `POST /api/workouts/recommend`
- `GET /api/workouts/history?userId=<guid>`
- Swagger UI: `/swagger`
## Database
- Provider: PostgreSQL
- Migration project: `GymLogAI.Persistence`
- Initial migration is included in `GymLogAI.Persistence/Migrations`
- `Exercise` includes an `Embedding` field stored as nullable `real[]`
## Run
```bash
dotnet build
dotnet run --project GymLogAI.API/
dotnet run --project GymLogAI.TelegramBot/
dotnet run --project GymLogAI.Worker/
docker compose -f compose.yaml up --build
```
## Configuration
- Connection string: `ConnectionStrings:Postgres`
- Telegram bot token: `TelegramBot:BotToken`
- OpenRouter settings: `OpenRouter:*`
- Worker polling settings: `Worker:BatchSize`, `Worker:PollingIntervalSeconds`
+45 -1
View File
@@ -1,7 +1,51 @@
services: services:
postgres:
image: postgres:17
environment:
POSTGRES_DB: gymlogai
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
gymlogai.api: gymlogai.api:
image: gymlogai.api image: gymlogai.api
build: build:
context: . context: .
dockerfile: GymLogAI.API/Dockerfile dockerfile: GymLogAI.API/Dockerfile
depends_on:
- postgres
environment:
ASPNETCORE_ENVIRONMENT: Development
ConnectionStrings__Postgres: Host=postgres;Port=5432;Database=gymlogai;Username=postgres;Password=postgres
ports:
- "5202:8080"
- "7225:8081"
gymlogai.telegrambot:
image: gymlogai.telegrambot
build:
context: .
dockerfile: GymLogAI.TelegramBot/Dockerfile
depends_on:
- postgres
environment:
DOTNET_ENVIRONMENT: Development
ConnectionStrings__Postgres: Host=postgres;Port=5432;Database=gymlogai;Username=postgres;Password=postgres
TelegramBot__BotToken: ""
gymlogai.worker:
image: gymlogai.worker
build:
context: .
dockerfile: GymLogAI.Worker/Dockerfile
depends_on:
- postgres
environment:
DOTNET_ENVIRONMENT: Development
ConnectionStrings__Postgres: Host=postgres;Port=5432;Database=gymlogai;Username=postgres;Password=postgres
volumes:
postgres_data: