using System; using System.Net.Http; using System.Net.Http.Headers; using System.Net.Http.Json; using System.Reflection; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Terius.Automate.Agent.Models; namespace Terius.Automate.Agent.Services; public sealed class ApiClient { private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); private readonly AgentConfig _config; private readonly HttpClient _http; // La API almacena la versión semántica de tres segmentos; el número de build // se mantiene en los metadatos del ejecutable y en la interfaz del agente. public string Version { get; } = Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "0.1.0"; public ApiClient(AgentConfig config) { _config = config; _http = new HttpClient { BaseAddress = new Uri(config.ServerUrl.TrimEnd('/') + "/"), Timeout = TimeSpan.FromSeconds(30), }; _http.DefaultRequestHeaders.UserAgent.ParseAdd($"TERIUS-Robot-Agent/{Version}"); if (!string.IsNullOrWhiteSpace(config.AgentSecret)) { _http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", config.AgentSecret); } } public async Task RegisterAsync(string token, CancellationToken cancellationToken) { var request = new RegisterRequest { Token = token, AgentId = _config.AgentId, Name = _config.Name, MachineName = Environment.MachineName, Version = Version, }; var result = await PostAsync("api/v1/agents/register", request, cancellationToken); if (!result.Ok || string.IsNullOrWhiteSpace(result.AgentSecret)) { throw new InvalidOperationException(result.Error ?? "El servidor no entregó la credencial del agente."); } return result; } public async Task HeartbeatAsync(CancellationToken cancellationToken) { var response = await PostAsync("api/v1/agents/heartbeat", new HeartbeatRequest { Name = _config.Name, MachineName = Environment.MachineName, Version = Version, }, cancellationToken); EnsureOk(response); } public async Task GetNextJobAsync(CancellationToken cancellationToken) { var response = await PostAsync("api/v1/jobs/next", new { }, cancellationToken); if (!response.Ok) { throw new InvalidOperationException(response.Error ?? "No fue posible consultar trabajos."); } return response.Job; } public async Task StartJobAsync(long id, CancellationToken cancellationToken) { EnsureOk(await PostAsync($"api/v1/jobs/{id}/start", new { }, cancellationToken)); } public async Task LogAsync(long id, string level, string message, CancellationToken cancellationToken) { EnsureOk(await PostAsync($"api/v1/jobs/{id}/log", new LogRequest { Level = level, Message = message }, cancellationToken)); } public async Task FinishJobAsync(long id, ExecutionResult result, CancellationToken cancellationToken) { EnsureOk(await PostAsync($"api/v1/jobs/{id}/finish", new FinishRequest { Success = result.Success, Result = new { messages = result.Messages }, Error = result.Error, }, cancellationToken)); } private async Task PostAsync(string path, TRequest request, CancellationToken cancellationToken) { using var response = await _http.PostAsJsonAsync(path, request, JsonOptions, cancellationToken); var content = await response.Content.ReadAsStringAsync(cancellationToken); TResponse? result; try { result = JsonSerializer.Deserialize(content, JsonOptions); } catch (JsonException) { var mediaType = response.Content.Headers.ContentType?.MediaType ?? "sin tipo"; const int maximumLogLength = 262144; var loggedContent = content.Length > maximumLogLength ? content[..maximumLogLength] + "\n[RESPUESTA TRUNCADA A 256 KB]" : content; LocalLog.Write($"Respuesta HTTP no JSON. Ruta: {path}; Estado: {(int)response.StatusCode}; Tipo: {mediaType}; Cuerpo completo:\n{loggedContent}"); var preview = content.Replace('\r', ' ').Replace('\n', ' ').Trim(); if (preview.Length > 140) { preview = preview[..140]; } if (preview.Length == 0) { preview = "respuesta vacía"; } throw new InvalidOperationException($"Respuesta inválida ({(int)response.StatusCode}, {mediaType}). Inicio: {preview}"); } if (!response.IsSuccessStatusCode) { var apiError = JsonSerializer.Deserialize(content, JsonOptions); throw new InvalidOperationException(apiError?.Error ?? $"Error HTTP {(int)response.StatusCode}."); } return result ?? throw new InvalidOperationException("El servidor respondió sin contenido válido."); } private static void EnsureOk(BasicResponse response) { if (!response.Ok) { throw new InvalidOperationException(response.Error ?? "La operación fue rechazada por el servidor."); } } }