diff --git a/JOBot.Backend/Controllers/HeadHunterHookController.cs b/JOBot.Backend/Controllers/HeadHunterHookController.cs new file mode 100644 index 0000000..1ba1e24 --- /dev/null +++ b/JOBot.Backend/Controllers/HeadHunterHookController.cs @@ -0,0 +1,23 @@ +using JOBot.Backend.Services; +using Microsoft.AspNetCore.Mvc; + +namespace JOBot.Backend.Controllers; + +[ApiController] +[Route("auth")] +public class HeadHunterHookController(HeadHunterService hhService) + : ControllerBase +{ + + [HttpGet] + public async Task Get(int userId, string? error, string code) + { + var res = await hhService.AuthUser(userId, code, error); + return res switch + { + HeadHunterService.Status.Success => Ok("Авторизация завершена успешно. Вернитесь в Telegram для продолжения."), + HeadHunterService.Status.UserNotFoundError => NotFound("Пользователь не найден."), + _ => BadRequest("Авторизация завершена с ошибкой. Вернитесь в Telegram для продолжения.") //TODO: Add resource + }; + } +} \ No newline at end of file diff --git a/JOBot.Backend/DTOs/HeadHunterHook/HeadHunterTokenResponseDto.cs b/JOBot.Backend/DTOs/HeadHunterHook/HeadHunterTokenResponseDto.cs new file mode 100644 index 0000000..179cfb3 --- /dev/null +++ b/JOBot.Backend/DTOs/HeadHunterHook/HeadHunterTokenResponseDto.cs @@ -0,0 +1,14 @@ +using System.Text.Json.Serialization; + +namespace JOBot.Backend.DTOs.HeadHunterHook; + +public record HeadHunterTokenResponseDto( + [property:JsonPropertyName("access_token")] + string AccessToken, + [property:JsonPropertyName("expires_in")] + int ExpiresIn, + [property:JsonPropertyName("refresh_token")] + string RefreshToken, + [property:JsonPropertyName("token_type")] + string TokenType + ); \ No newline at end of file diff --git a/JOBot.Backend/Dockerfile b/JOBot.Backend/Dockerfile index 6073e57..da0d968 100644 --- a/JOBot.Backend/Dockerfile +++ b/JOBot.Backend/Dockerfile @@ -10,5 +10,4 @@ WORKDIR /app COPY --from=build /app . EXPOSE 5001 EXPOSE 5000 -ENV ASPNETCORE_ENVIRONMENT Staging ENTRYPOINT ["dotnet", "JOBot.Backend.dll"] \ No newline at end of file diff --git a/JOBot.Backend/Infrastructure/Config/HeadHunterConfig.cs b/JOBot.Backend/Infrastructure/Config/HeadHunterConfig.cs new file mode 100644 index 0000000..e4c1d15 --- /dev/null +++ b/JOBot.Backend/Infrastructure/Config/HeadHunterConfig.cs @@ -0,0 +1,18 @@ +namespace JOBot.Backend.Infrastructure.Config; + +public class HeadHunterConfig +{ + public const string SectionName = "HeadHunter"; + public required HeadHunterLinksConfig Links { get; init; } + public required string ClientId { get; init; } + public required string Secret { get; init; } + + public class HeadHunterLinksConfig + { + public required string AuthLink { get; init; } + public required string HookDomain { get; init; } + public required string HookRoute { get; init; } + public required string HeadHunterApiDomain { get; init; } + public required string HeadHunterTokenRoute { get; init; } + } +} \ No newline at end of file diff --git a/JOBot.Backend/JOBot.Backend.csproj b/JOBot.Backend/JOBot.Backend.csproj index 2c4b3c8..1f9a3c1 100644 --- a/JOBot.Backend/JOBot.Backend.csproj +++ b/JOBot.Backend/JOBot.Backend.csproj @@ -14,6 +14,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/JOBot.Backend/Services/HeadHunterService.cs b/JOBot.Backend/Services/HeadHunterService.cs new file mode 100644 index 0000000..e89ce38 --- /dev/null +++ b/JOBot.Backend/Services/HeadHunterService.cs @@ -0,0 +1,113 @@ +using System.Text.Json; +using System.Web; +using JOBot.Backend.DAL.Context; +using JOBot.Backend.DTOs.HeadHunterHook; +using JOBot.Backend.Infrastructure.Config; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace JOBot.Backend.Services; + +public class HeadHunterService(ILogger logger, IOptions config, AppDbContext dbContext) +{ + private readonly HeadHunterConfig _config = config.Value; + + /// + /// Generate HeadHunter oauth authorization link + /// + /// Telegram UserId + /// Link for auth + public string GenerateAuthLink(long userId) + { + + return string.Format(_config.Links.AuthLink, [_config.ClientId, GetRedirectUrl(userId)]); + } + + public async Task AuthUser(int userId, string authorizationCode, string? error) + { + logger.LogInformation($"Authorization for user {userId} in process..."); + + if (!string.IsNullOrEmpty(error)) + { + logger.LogWarning($"User {userId} auth completed with error {error}"); + return Status.UserAuthRejectedError; + } + + using var client = new HttpClient(); + var form = new Dictionary + { + { "client_id", _config.ClientId }, + { "client_secret", _config.Secret }, + { "code", authorizationCode }, + { "grant_type", "authorization_code" }, + { "redirect_uri", GetRedirectUrl(userId) } + }; + client.BaseAddress = new UriBuilder(_config.Links.HeadHunterApiDomain) + { + Port = -1, + Scheme = "https" + }.Uri; + client.DefaultRequestHeaders.UserAgent.ParseAdd("Jobot BackEnd Service"); + + using var res = await client.SendAsync( + new HttpRequestMessage( + HttpMethod.Post, + _config.Links.HeadHunterTokenRoute) + { + Content = new FormUrlEncodedContent(form) + }); + + if (!res.IsSuccessStatusCode) + { + logger.LogWarning($"Response of HttpRequest {_config.Links.HeadHunterApiDomain}" + + $"{_config.Links.HeadHunterTokenRoute} has unsuccessful status code {res.StatusCode}"); + logger.LogWarning($"{res.Content.ReadAsStringAsync().Result}"); + return Status.HeadHunterAuthRejectedError; + } + + var responseDto = JsonSerializer.Deserialize(await res.Content.ReadAsStringAsync()); + + if (responseDto == null) + { + logger.LogWarning($"User {userId} auth completed with error " + + $"{nameof(Status.HeadHunterResponseDeserializationFailedError)}"); + return Status.HeadHunterResponseDeserializationFailedError; + } + + var user = await dbContext.Users.FirstOrDefaultAsync(x => x.UserId == userId); + + if (user == null) + { + logger.LogWarning($"User {userId} search completed with error {nameof(Status.UserNotFoundError)}"); + return Status.UserNotFoundError; + } + + user.AccessToken = responseDto.AccessToken; + user.RefreshToken = responseDto.RefreshToken; + + await dbContext.SaveChangesAsync(); + + logger.LogInformation($"User {userId} auth completed!"); + return Status.Success; + } + + private string GetRedirectUrl(long userId) + { + return new UriBuilder(_config.Links.HookDomain) + { + Port = -1, + Scheme = "https", + Path = _config.Links.HookRoute, + Query = $"?userId={userId}" + }.ToString(); + } + + public enum Status + { + UserAuthRejectedError, + HeadHunterAuthRejectedError, + UserNotFoundError, + HeadHunterResponseDeserializationFailedError, + Success + } +} \ No newline at end of file diff --git a/JOBot.Backend/Services/gRPC/UserService.cs b/JOBot.Backend/Services/gRPC/UserService.cs index f694431..4a1cfe6 100644 --- a/JOBot.Backend/Services/gRPC/UserService.cs +++ b/JOBot.Backend/Services/gRPC/UserService.cs @@ -1,3 +1,4 @@ +using Google.Protobuf.WellKnownTypes; using Grpc.Core; using JOBot.Backend.DAL.Context; using JOBot.Backend.DAL.Models; @@ -7,7 +8,7 @@ using User = JOBot.Backend.DAL.Models.User; namespace JOBot.Backend.Services.gRPC; -public class UserService(AppDbContext dbContext) : Proto.User.UserBase +public class UserService(AppDbContext dbContext, HeadHunterService hhService) : Proto.User.UserBase { /// /// Create user @@ -62,6 +63,16 @@ public class UserService(AppDbContext dbContext) : Proto.User.UserBase return new AcceptEulaResponse { Success = true }; } + public override Task GetHeadHunterAuthHook( + GetHeadHunterAuthHookRequest request, + ServerCallContext context) + { + return Task.Run(() => new GetHeadHunterAuthHookResponse + { + RegistrationUrl = hhService.GenerateAuthLink(request.UserId) + }); + } + /// /// Throw RPCException if user not found /// diff --git a/JOBot.Backend/Startup.cs b/JOBot.Backend/Startup.cs index 0186b3b..1295739 100644 --- a/JOBot.Backend/Startup.cs +++ b/JOBot.Backend/Startup.cs @@ -1,30 +1,34 @@ using JOBot.Backend.DAL.Context; +using JOBot.Backend.Infrastructure.Config; +using JOBot.Backend.Services; using JOBot.Backend.Services.gRPC; using Microsoft.EntityFrameworkCore; namespace JOBot.Backend; -public class Startup +public class Startup(IConfiguration configuration) { - public Startup(IConfiguration configuration) - { - Configuration = configuration; - } - - public IConfiguration Configuration { get; } + private IConfiguration Configuration { get; } = configuration; public void ConfigureServices(IServiceCollection services) { services.AddGrpc(); services.AddGrpcReflection(); + services.AddControllers(); + services.AddLogging(); services.AddDbContext(options => options.UseNpgsql(Configuration.GetConnectionString("PostgreSQL"))); + + services.Configure(Configuration.GetSection(HeadHunterConfig.SectionName)); + + services.AddScoped(); } public void Configure(WebApplication app, IWebHostEnvironment env) { app.MapGrpcReflectionService().AllowAnonymous(); app.MapGrpcService(); + app.MapControllers(); } } \ No newline at end of file diff --git a/JOBot.Backend/appsettings.json b/JOBot.Backend/appsettings.json index 6bb04e6..ae7b3b2 100644 --- a/JOBot.Backend/appsettings.json +++ b/JOBot.Backend/appsettings.json @@ -9,6 +9,17 @@ "ConnectionStrings": { "PostgreSQL": "Host=postgres;Port=5432;Database=jobot;Username=postgres;Password=LocalDbPass" }, + "HeadHunter": { + "Links": { + "AuthLink": "https://hh.ru/oauth/authorize?response_type=code&client_id={0}&redirect_uri={1}", + "HookDomain": "jobot.lisoveliy.su", + "HookRoute": "/auth", + "HeadHunterApiDomain": "api.hh.ru", + "HeadHunterTokenRoute": "/token" + }, + "ClientId": "", + "Secret": "" + }, "Kestrel": { "Endpoints": { "gRPC": { diff --git a/JOBot.TClient/Commands/Buttons/EulaAgreementButtonCommand.cs b/JOBot.TClient/Commands/Buttons/EulaAgreementButtonCommand.cs index 87d6368..f28e9ab 100644 --- a/JOBot.TClient/Commands/Buttons/EulaAgreementButtonCommand.cs +++ b/JOBot.TClient/Commands/Buttons/EulaAgreementButtonCommand.cs @@ -12,6 +12,7 @@ public class EulaAgreementButtonCommand(PrepareUserState prepareUserState) : IAu { public async Task ExecuteAsync(Update update, GetUserResponse user, CancellationToken ct) { - await prepareUserState.AcceptEula(update, ct); + if (!user.Eula) + await prepareUserState.AcceptEula(user, update, ct); } } \ No newline at end of file diff --git a/JOBot.TClient/JOBot.TClient.csproj b/JOBot.TClient/JOBot.TClient.csproj index e8b6287..987d35a 100644 --- a/JOBot.TClient/JOBot.TClient.csproj +++ b/JOBot.TClient/JOBot.TClient.csproj @@ -57,4 +57,10 @@ + + + PreserveNewest + + + diff --git a/JOBot.TClient/Services/MenuService.cs b/JOBot.TClient/Services/MenuService.cs index 9849490..e8ba02e 100644 --- a/JOBot.TClient/Services/MenuService.cs +++ b/JOBot.TClient/Services/MenuService.cs @@ -6,12 +6,10 @@ namespace JOBot.TClient.Services; public class MenuService(ITelegramBotClient bot) { - public Task RenderMenu(Update update, CancellationToken ct = default) + public async Task RenderMenu(Update update, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(update.Message?.From); - bot.SendMessage(update.Message.From.Id,"PrepareUser stage is done.", cancellationToken: ct); - - return Task.CompletedTask; + await bot.SendMessage(update.Message.From.Id,"PrepareUser stage is done.", cancellationToken: ct); } } \ No newline at end of file diff --git a/JOBot.TClient/Services/PrepareUserService.cs b/JOBot.TClient/Services/PrepareUserService.cs index 4f2921e..443f8b0 100644 --- a/JOBot.TClient/Services/PrepareUserService.cs +++ b/JOBot.TClient/Services/PrepareUserService.cs @@ -83,4 +83,19 @@ public class PrepareUserService(ITelegramBotClient bot, User.UserClient userClie if (!result.Success) throw new FallbackException(TextResource.FallbackMessage, update.Message, _bot); } + + public async Task Auth(Update update, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(update.Message?.From); + + var url = await _userClient.GetHeadHunterAuthHookAsync(new GetHeadHunterAuthHookRequest() + { + UserId = update.Message.From.Id + }); + + await _bot.SendMessage( + update.Message.From.Id, + string.Format(TextResource.AskForAuth, [url.RegistrationUrl]), + cancellationToken: ct); + } } \ No newline at end of file diff --git a/JOBot.TClient/Statements/PrepareUserState.cs b/JOBot.TClient/Statements/PrepareUserState.cs index 855ebe4..b989aec 100644 --- a/JOBot.TClient/Statements/PrepareUserState.cs +++ b/JOBot.TClient/Statements/PrepareUserState.cs @@ -1,3 +1,4 @@ +using JOBot.Proto; using JOBot.TClient.Services; using Telegram.Bot.Types; using User = JOBot.Proto.User; @@ -21,27 +22,34 @@ public class PrepareUserState(PrepareUserService prepareUserService, MenuService return; //interrupt while eula isn't accepted } - await OnUserEulaValidStage(update, ct); + await OnUserEulaValidStage(user, update, ct); } /// /// Signal for accepted eula /// + /// /// /// - public async Task AcceptEula(Update update, CancellationToken ct = default) + public async Task AcceptEula(GetUserResponse user, Update update, CancellationToken ct = default) { await prepareUserService.AcceptEula(update, ct: ct); - await OnUserEulaValidStage(update, ct); + await OnUserEulaValidStage(user, update, ct); } - + /// /// Continue prepare stage /// + /// /// /// - private async Task OnUserEulaValidStage(Update update, CancellationToken ct = default) + private async Task OnUserEulaValidStage(GetUserResponse user, Update update, CancellationToken ct = default) { + if (!user.IsLogged) + { + await prepareUserService.Auth(update, ct); + return; + } await menuService.RenderMenu(update, ct); //boilerplate } } \ No newline at end of file diff --git a/JOBot.TClient/TextResource.Designer.cs b/JOBot.TClient/TextResource.Designer.cs index 3104beb..088802e 100644 --- a/JOBot.TClient/TextResource.Designer.cs +++ b/JOBot.TClient/TextResource.Designer.cs @@ -59,6 +59,15 @@ namespace JOBot.TClient { } } + /// + /// Looks up a localized string similar to Авторизируйтесь на сайте HeadHunter для получения доступа к резюме и вакансиям {0}. + /// + public static string AskForAuth { + get { + return ResourceManager.GetString("AskForAuth", resourceCulture); + } + } + /// /// Looks up a localized string similar to Команда не найдена, попробуйте что-то другое. /// diff --git a/JOBot.TClient/TextResource.resx b/JOBot.TClient/TextResource.resx index 3d541c9..7bed097 100644 --- a/JOBot.TClient/TextResource.resx +++ b/JOBot.TClient/TextResource.resx @@ -34,4 +34,7 @@ https://hh.ru/account/agreement?backurl=%2Faccount%2Fsignup%3Fbackurl%3D%252F%26 Это бот для упрощения поиска работы на HH.ru + + Авторизируйтесь на сайте HeadHunter для получения доступа к резюме и вакансиям {0} + \ No newline at end of file diff --git a/Proto/user.proto b/Proto/user.proto index 98072c3..687316c 100644 --- a/Proto/user.proto +++ b/Proto/user.proto @@ -1,14 +1,16 @@ syntax = "proto3"; option csharp_namespace = "JOBot.Proto"; +import "google/protobuf/wrappers.proto"; +import "google/protobuf/empty.proto"; + service User { rpc Register (RegisterRequest) returns (RegisterResponse); rpc GetUser (GetUserRequest) returns (GetUserResponse); rpc AcceptEula (AcceptEulaRequest) returns (AcceptEulaResponse); + rpc GetHeadHunterAuthHook(GetHeadHunterAuthHookRequest) returns (GetHeadHunterAuthHookResponse); } -import "google/protobuf/wrappers.proto"; - message RegisterRequest{ int64 user_id = 1; google.protobuf.StringValue username = 2; @@ -37,4 +39,12 @@ message AcceptEulaRequest { message AcceptEulaResponse{ bool success = 1; +} + +message GetHeadHunterAuthHookRequest{ + int64 user_id = 1; +} + +message GetHeadHunterAuthHookResponse{ + string registration_url = 1; } \ No newline at end of file diff --git a/compose.dev.yml b/compose.dev.yml index 4bb2897..7770047 100644 --- a/compose.dev.yml +++ b/compose.dev.yml @@ -20,6 +20,7 @@ services: depends_on: - postgres ports: + - "5000:5000" - "5001:5001" networks: - jobot diff --git a/compose.yml b/compose.yml index da54fa4..5c87703 100644 --- a/compose.yml +++ b/compose.yml @@ -17,6 +17,8 @@ services: dockerfile: JOBot.Backend/Dockerfile depends_on: - postgres + ports: + - "5000:5000" networks: - jobot