| 1 | using System.Net.Http.Headers; |
| 2 | using System.Net.Http.Json; |
| 3 | using System.Text; |
| 4 | using System.Text.Json; |
| 5 | using CascadeIDE.Models; |
| 6 | using CascadeIDE.Services; |
| 7 | |
| 8 | namespace CascadeIDE.Features.Intercom.Transport; |
| 9 | |
| 10 | /// <summary>HTTP-клиент reference Intercom service (ADR 0144).</summary> |
| 11 | public sealed partial class IntercomTransportApiClient : IDisposable |
| 12 | { |
| 13 | private readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(120) }; |
| 14 | |
| 15 | public void ConfigureBaseUrl(string baseUrl) |
| 16 | { |
| 17 | var trimmed = baseUrl.Trim().TrimEnd('/'); |
| 18 | _http.BaseAddress = new Uri(trimmed + "/"); |
| 19 | } |
| 20 | |
| 21 | public async Task<IReadOnlyList<IntercomTopicDto>> ListTopicsAsync( |
| 22 | string teamId, |
| 23 | string bearerToken, |
| 24 | CancellationToken ct) |
| 25 | { |
| 26 | using var req = Authorized(HttpMethod.Get, $"/api/v1/teams/{Uri.EscapeDataString(teamId)}/topics", bearerToken); |
| 27 | using var res = await _http.SendAsync(req, ct).ConfigureAwait(false); |
| 28 | res.EnsureSuccessStatusCode(); |
| 29 | var list = await res.Content.ReadFromJsonAsync<List<IntercomTopicDto>>(IntercomTransportJson.Web, ct) |
| 30 | .ConfigureAwait(false); |
| 31 | return list ?? []; |
| 32 | } |
| 33 | |
| 34 | public async Task<IntercomTransportEventEnvelopeDto?> AppendEventAsync( |
| 35 | string topicId, |
| 36 | IntercomAppendEventRequestDto body, |
| 37 | string bearerToken, |
| 38 | CancellationToken ct) |
| 39 | { |
| 40 | var json = JsonSerializer.Serialize(body, IntercomTransportJson.Web); |
| 41 | using var req = Authorized( |
| 42 | HttpMethod.Post, |
| 43 | $"/api/v1/topics/{Uri.EscapeDataString(topicId)}/events", |
| 44 | bearerToken); |
| 45 | req.Content = new StringContent(json, Encoding.UTF8, "application/json"); |
| 46 | using var res = await _http.SendAsync(req, ct).ConfigureAwait(false); |
| 47 | if (!res.IsSuccessStatusCode) |
| 48 | return null; |
| 49 | return await res.Content.ReadFromJsonAsync<IntercomTransportEventEnvelopeDto>(IntercomTransportJson.Web, ct) |
| 50 | .ConfigureAwait(false); |
| 51 | } |
| 52 | |
| 53 | public async Task<IReadOnlyList<IntercomTransportEventEnvelopeDto>> ListEventsAsync( |
| 54 | string topicId, |
| 55 | long afterSeq, |
| 56 | string bearerToken, |
| 57 | CancellationToken ct) |
| 58 | { |
| 59 | var path = $"/api/v1/topics/{Uri.EscapeDataString(topicId)}/events?after_seq={afterSeq}&limit=200"; |
| 60 | using var req = Authorized(HttpMethod.Get, path, bearerToken); |
| 61 | using var res = await _http.SendAsync(req, ct).ConfigureAwait(false); |
| 62 | res.EnsureSuccessStatusCode(); |
| 63 | var list = await res.Content.ReadFromJsonAsync<List<IntercomTransportEventEnvelopeDto>>( |
| 64 | IntercomTransportJson.Web, |
| 65 | ct) |
| 66 | .ConfigureAwait(false); |
| 67 | return list ?? []; |
| 68 | } |
| 69 | |
| 70 | public async Task<IReadOnlyList<IntercomTransportEventEnvelopeDto>> ListTeamEventsAsync( |
| 71 | string teamId, |
| 72 | long afterSeq, |
| 73 | string bearerToken, |
| 74 | CancellationToken ct) |
| 75 | { |
| 76 | var path = $"/api/v1/teams/{Uri.EscapeDataString(teamId)}/events?after_seq={afterSeq}&limit=500"; |
| 77 | using var req = Authorized(HttpMethod.Get, path, bearerToken); |
| 78 | using var res = await _http.SendAsync(req, ct).ConfigureAwait(false); |
| 79 | res.EnsureSuccessStatusCode(); |
| 80 | var list = await res.Content.ReadFromJsonAsync<List<IntercomTransportEventEnvelopeDto>>( |
| 81 | IntercomTransportJson.Web, |
| 82 | ct) |
| 83 | .ConfigureAwait(false); |
| 84 | return list ?? []; |
| 85 | } |
| 86 | |
| 87 | public async Task<IntercomTopicDto?> EnsureTopicAsync( |
| 88 | string teamId, |
| 89 | string spineKey, |
| 90 | string title, |
| 91 | string bearerToken, |
| 92 | CancellationToken ct) |
| 93 | { |
| 94 | var body = new IntercomEnsureTopicRequestDto(spineKey, title); |
| 95 | using var req = Authorized( |
| 96 | HttpMethod.Post, |
| 97 | $"/api/v1/teams/{Uri.EscapeDataString(teamId)}/topics/ensure", |
| 98 | bearerToken); |
| 99 | req.Content = JsonContent.Create(body, options: IntercomTransportJson.Web); |
| 100 | using var res = await _http.SendAsync(req, ct).ConfigureAwait(false); |
| 101 | if (!res.IsSuccessStatusCode) |
| 102 | return null; |
| 103 | return await res.Content.ReadFromJsonAsync<IntercomTopicDto>(IntercomTransportJson.Web, ct).ConfigureAwait(false); |
| 104 | } |
| 105 | |
| 106 | public async Task<IntercomTokenResponseDto?> RefreshTokenAsync(string refreshToken, CancellationToken ct) |
| 107 | { |
| 108 | var body = new OAuthTokenRequest("refresh_token", refreshToken, null, null, null, null); |
| 109 | return await postTokenAsync(body, ct).ConfigureAwait(false); |
| 110 | } |
| 111 | |
| 112 | public async Task<IntercomTokenResponseDto?> ExchangeAuthorizationCodeAsync( |
| 113 | string code, |
| 114 | string state, |
| 115 | string codeVerifier, |
| 116 | string redirectUri, |
| 117 | CancellationToken ct) |
| 118 | { |
| 119 | var body = new OAuthTokenRequest( |
| 120 | "authorization_code", |
| 121 | null, |
| 122 | code, |
| 123 | state, |
| 124 | codeVerifier, |
| 125 | redirectUri); |
| 126 | return await postTokenAsync(body, ct).ConfigureAwait(false); |
| 127 | } |
| 128 | |
| 129 | private async Task<IntercomTokenResponseDto?> postTokenAsync(OAuthTokenRequest body, CancellationToken ct) |
| 130 | { |
| 131 | using var res = await _http.PostAsJsonAsync("/api/v1/auth/token", body, IntercomTransportJson.Web, ct) |
| 132 | .ConfigureAwait(false); |
| 133 | if (!res.IsSuccessStatusCode) |
| 134 | return null; |
| 135 | return await res.Content.ReadFromJsonAsync<IntercomTokenResponseDto>(IntercomTransportJson.Web, ct) |
| 136 | .ConfigureAwait(false); |
| 137 | } |
| 138 | |
| 139 | public async Task LogoutAsync(string refreshToken, string accessToken, CancellationToken ct) |
| 140 | { |
| 141 | var body = new { grant_type = "refresh_token", refresh_token = refreshToken }; |
| 142 | using var req = Authorized(HttpMethod.Post, "/api/v1/auth/logout", accessToken); |
| 143 | req.Content = JsonContent.Create(body, options: IntercomTransportJson.Web); |
| 144 | using var res = await _http.SendAsync(req, ct).ConfigureAwait(false); |
| 145 | _ = res.StatusCode; |
| 146 | } |
| 147 | |
| 148 | public async Task<IntercomMeResponseDto?> GetMeAsync(string bearerToken, CancellationToken ct) |
| 149 | { |
| 150 | using var req = Authorized(HttpMethod.Get, "/api/v1/auth/me", bearerToken); |
| 151 | using var res = await _http.SendAsync(req, ct).ConfigureAwait(false); |
| 152 | if (!res.IsSuccessStatusCode) |
| 153 | return null; |
| 154 | return await res.Content.ReadFromJsonAsync<IntercomMeResponseDto>(IntercomTransportJson.Web, ct) |
| 155 | .ConfigureAwait(false); |
| 156 | } |
| 157 | |
| 158 | public async Task<IReadOnlyList<IntercomAuthProviderDto>> ListAuthProvidersAsync(CancellationToken ct) |
| 159 | { |
| 160 | using var res = await _http.GetAsync("/api/v1/auth/providers", ct).ConfigureAwait(false); |
| 161 | if (!res.IsSuccessStatusCode) |
| 162 | return []; |
| 163 | var list = await res.Content.ReadFromJsonAsync<List<IntercomAuthProviderDto>>(IntercomTransportJson.Web, ct) |
| 164 | .ConfigureAwait(false); |
| 165 | return list ?? []; |
| 166 | } |
| 167 | |
| 168 | public async Task<IntercomWorkspaceContextResponseDto?> ResolveWorkspaceContextAsync( |
| 169 | string normalizedRepoUrl, |
| 170 | string bearerToken, |
| 171 | CancellationToken ct) |
| 172 | { |
| 173 | var path = $"/api/v1/resolve/workspace-context?repo_url={Uri.EscapeDataString(normalizedRepoUrl)}"; |
| 174 | using var req = Authorized(HttpMethod.Get, path, bearerToken); |
| 175 | using var res = await _http.SendAsync(req, ct).ConfigureAwait(false); |
| 176 | if (!res.IsSuccessStatusCode) |
| 177 | return null; |
| 178 | return await res.Content.ReadFromJsonAsync<IntercomWorkspaceContextResponseDto>(IntercomTransportJson.Web, ct) |
| 179 | .ConfigureAwait(false); |
| 180 | } |
| 181 | |
| 182 | public HttpRequestMessage CreateSseRequest(string teamId, string? topicId, string bearerToken) |
| 183 | { |
| 184 | var path = $"/api/v1/teams/{Uri.EscapeDataString(teamId)}/stream"; |
| 185 | if (!string.IsNullOrWhiteSpace(topicId)) |
| 186 | path += $"?topic_id={Uri.EscapeDataString(topicId)}"; |
| 187 | return Authorized(HttpMethod.Get, path, bearerToken); |
| 188 | } |
| 189 | |
| 190 | public Task<HttpResponseMessage> SendSseAsync(HttpRequestMessage request, CancellationToken ct) => |
| 191 | _http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct); |
| 192 | |
| 193 | public static void ApplyTokenResponse(IntercomTransportSecrets secrets, IntercomTokenResponseDto tokens) |
| 194 | { |
| 195 | secrets.AccessToken = tokens.AccessToken; |
| 196 | secrets.RefreshToken = tokens.RefreshToken; |
| 197 | secrets.SetAccessExpiry(DateTimeOffset.UtcNow.AddSeconds(Math.Max(30, tokens.ExpiresIn - 30))); |
| 198 | IntercomTransportSecretsStorage.Save(secrets); |
| 199 | } |
| 200 | |
| 201 | private static HttpRequestMessage Authorized(HttpMethod method, string relativePath, string bearerToken) |
| 202 | { |
| 203 | var req = new HttpRequestMessage(method, relativePath); |
| 204 | req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearerToken); |
| 205 | return req; |
| 206 | } |
| 207 | |
| 208 | public void Dispose() => _http.Dispose(); |
| 209 | } |
| 210 | |