// Drop-in C# / .NET client library for the Friendship Tracker HTTP API. // // Save this file under your project as `FriendshipClient.cs`, in a // directory matching `namespace friendship_client;`, then call the // FriendshipClient class: // // using friendship_client; // var c = new FriendshipClient("pat_..."); // var rows = await c.AccountListAsync(new ListOpts { Limit = 20 }); // var fresh = await c.AccountCreateAsync(new Dictionary { ["name"] = "Example GmbH" }); // // Every endpoint exposed by the HTTP API is wrapped as an // `Async` method on FriendshipClient. List methods take ListOpts; // get/update/delete methods take the row id as their first argument. // // Provided as-is, with no warranty. Vendor freely; modify as needed. // Targets .NET 6+; uses only HttpClient and System.Text.Json from the // BCL. No NuGet packages required. // // DO NOT EDIT THIS FILE MANUALLY - re-download from the docs site. // Local edits will be overwritten by the once-per-day version check. using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Net; using System.Net.Http; using System.Net.Http.Headers; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; using System.Threading; using System.Threading.Tasks; namespace friendship_client; /// API client wrapper for Friendship Tracker. public sealed class FriendshipClient { public const string AppSlug = "friendship"; public const string AppName = "Friendship Tracker"; public const string ModuleName = "friendship_client"; public const string ClientVersion = "0.3.13"; public const string Language = "csharp"; private const string DefaultBase = "https://friendship-tracker.com"; /// Per-type metadata baked at generation time; parse with /// JsonNode.Parse if you need the legal filters / sorts / max_limit /// for a model without an extra round-trip. public const string TypesJson = "{\"activity\":{\"ops\":[\"list\",\"read\",\"create\",\"update\",\"delete\"],\"create_fields\":[\"parent_id\",\"kind\",\"summary\",\"description\",\"occurred_at\",\"location\"],\"update_fields\":[\"kind\",\"summary\",\"description\",\"occurred_at\",\"location\"],\"allowed_filters\":[\"data__parent_id\",\"data__kind\",\"status\",\"is_archived\",\"owned_by\"],\"allowed_sorts\":[\"created_at\",\"data__occurred_at\"],\"default_sort\":\"data__occurred_at\",\"max_limit\":200,\"fields\":[{\"name\":\"kind\",\"type\":\"enum\",\"values\":[\"meeting\",\"call\",\"email\",\"message\",\"event\",\"other\"]},{\"name\":\"summary\",\"type\":\"string\",\"max_len\":400},{\"name\":\"location\",\"type\":\"string\",\"max_len\":200},{\"name\":\"parent_id\",\"type\":\"string\",\"max_len\":64,\"ref\":{\"type\":\"contact\",\"owned\":true,\"optional\":false}},{\"name\":\"description\",\"type\":\"string\",\"max_len\":4000},{\"name\":\"occurred_at\",\"type\":\"string\",\"max_len\":32}]},\"contact\":{\"ops\":[\"list\",\"read\",\"create\",\"update\",\"delete\"],\"create_fields\":[\"name\",\"nickname\",\"pronouns\",\"email\",\"phone\",\"secondary_email\",\"secondary_phone\",\"company\",\"job_title\",\"address_line\",\"city\",\"country\",\"website\",\"linkedin\",\"twitter\",\"birthday\",\"anniversary\",\"gender\",\"how_we_met\",\"food_prefs\",\"allergies\",\"last_contacted_at\",\"stay_in_touch_frequency\",\"stay_in_touch_topic\",\"notes\",\"tags\",\"favorite\",\"avatar_blob_id\",\"color\"],\"update_fields\":[\"name\",\"nickname\",\"pronouns\",\"email\",\"phone\",\"secondary_email\",\"secondary_phone\",\"company\",\"job_title\",\"address_line\",\"city\",\"country\",\"website\",\"linkedin\",\"twitter\",\"birthday\",\"anniversary\",\"gender\",\"how_we_met\",\"food_prefs\",\"allergies\",\"last_contacted_at\",\"stay_in_touch_frequency\",\"stay_in_touch_topic\",\"notes\",\"tags\",\"favorite\",\"avatar_blob_id\",\"color\"],\"allowed_filters\":[\"data__name\",\"data__email\",\"data__company\",\"data__city\",\"data__country\",\"data__favorite\",\"data__tags\",\"data__gender\",\"data__stay_in_touch_frequency\",\"status\",\"is_archived\",\"owned_by\"],\"allowed_sorts\":[\"created_at\",\"updated_at\",\"data__name\",\"data__company\",\"data__last_contacted_at\",\"data__birthday\",\"data__stay_in_touch_frequency\"],\"default_sort\":\"data__name\",\"max_limit\":200,\"fields\":[{\"name\":\"city\",\"type\":\"string\",\"max_len\":120},{\"name\":\"name\",\"type\":\"string\",\"max_len\":200},{\"name\":\"tags\",\"type\":\"tags\"},{\"name\":\"color\",\"type\":\"string\",\"max_len\":24},{\"name\":\"email\",\"type\":\"string\",\"max_len\":320},{\"name\":\"notes\",\"type\":\"string\",\"max_len\":8000},{\"name\":\"phone\",\"type\":\"string\",\"max_len\":64},{\"name\":\"gender\",\"type\":\"enum\",\"values\":[\"male\",\"female\",\"other\",\"unspecified\"]},{\"name\":\"company\",\"type\":\"string\",\"max_len\":200},{\"name\":\"country\",\"type\":\"string\",\"max_len\":120},{\"name\":\"twitter\",\"type\":\"url\"},{\"name\":\"website\",\"type\":\"url\"},{\"name\":\"birthday\",\"type\":\"string\",\"max_len\":32},{\"name\":\"favorite\",\"type\":\"bool\"},{\"name\":\"linkedin\",\"type\":\"url\"},{\"name\":\"nickname\",\"type\":\"string\",\"max_len\":120},{\"name\":\"pronouns\",\"type\":\"string\",\"max_len\":32},{\"name\":\"allergies\",\"type\":\"string\",\"max_len\":600},{\"name\":\"job_title\",\"type\":\"string\",\"max_len\":200},{\"name\":\"food_prefs\",\"type\":\"string\",\"max_len\":600},{\"name\":\"how_we_met\",\"type\":\"string\",\"max_len\":1000},{\"name\":\"anniversary\",\"type\":\"string\",\"max_len\":32},{\"name\":\"address_line\",\"type\":\"string\",\"max_len\":200},{\"name\":\"avatar_blob_id\",\"type\":\"string\",\"max_len\":64},{\"name\":\"secondary_email\",\"type\":\"string\",\"max_len\":320},{\"name\":\"secondary_phone\",\"type\":\"string\",\"max_len\":64},{\"name\":\"last_contacted_at\",\"type\":\"string\",\"max_len\":32},{\"name\":\"stay_in_touch_topic\",\"type\":\"string\",\"max_len\":400},{\"name\":\"stay_in_touch_frequency\",\"type\":\"enum\",\"values\":[\"never\",\"weekly\",\"biweekly\",\"monthly\",\"quarterly\",\"yearly\"]}]},\"conversation\":{\"ops\":[\"list\",\"read\",\"create\",\"update\",\"delete\"],\"create_fields\":[\"parent_id\",\"channel\",\"summary\",\"content\",\"occurred_at\",\"sentiment\",\"duration_minutes\"],\"update_fields\":[\"channel\",\"summary\",\"content\",\"occurred_at\",\"sentiment\",\"duration_minutes\"],\"allowed_filters\":[\"data__parent_id\",\"data__channel\",\"data__sentiment\",\"status\",\"is_archived\",\"owned_by\"],\"allowed_sorts\":[\"created_at\",\"data__occurred_at\"],\"default_sort\":\"data__occurred_at\",\"max_limit\":200,\"fields\":[{\"name\":\"channel\",\"type\":\"enum\",\"values\":[\"call\",\"sms\",\"whatsapp\",\"email\",\"in_person\",\"video\",\"voice\",\"letter\",\"other\"]},{\"name\":\"content\",\"type\":\"string\",\"max_len\":8000},{\"name\":\"summary\",\"type\":\"string\",\"max_len\":400},{\"name\":\"parent_id\",\"type\":\"string\",\"max_len\":64,\"ref\":{\"type\":\"contact\",\"owned\":true,\"optional\":false}},{\"name\":\"sentiment\",\"type\":\"enum\",\"values\":[\"positive\",\"neutral\",\"negative\"]},{\"name\":\"occurred_at\",\"type\":\"string\",\"max_len\":32},{\"name\":\"duration_minutes\",\"type\":\"number\"}]},\"custom_field\":{\"ops\":[\"list\",\"read\",\"create\",\"update\",\"delete\"],\"create_fields\":[\"parent_id\",\"label\",\"value\",\"kind\",\"icon\"],\"update_fields\":[\"label\",\"value\",\"kind\",\"icon\"],\"allowed_filters\":[\"data__parent_id\",\"data__kind\",\"status\",\"is_archived\",\"owned_by\"],\"allowed_sorts\":[\"created_at\",\"data__label\"],\"default_sort\":\"created_at\",\"max_limit\":200,\"fields\":[{\"name\":\"icon\",\"type\":\"string\",\"max_len\":32},{\"name\":\"kind\",\"type\":\"enum\",\"values\":[\"text\",\"number\",\"date\",\"url\",\"bool\"]},{\"name\":\"label\",\"type\":\"string\",\"max_len\":80},{\"name\":\"value\",\"type\":\"string\",\"max_len\":2000},{\"name\":\"parent_id\",\"type\":\"string\",\"max_len\":64,\"ref\":{\"type\":\"contact\",\"owned\":true,\"optional\":false}}]},\"gift\":{\"ops\":[\"list\",\"read\",\"create\",\"update\",\"delete\"],\"create_fields\":[\"parent_id\",\"title\",\"occasion\",\"status\",\"occurred_at\",\"price\",\"currency\",\"url\",\"notes\"],\"update_fields\":[\"title\",\"occasion\",\"status\",\"occurred_at\",\"price\",\"currency\",\"url\",\"notes\"],\"allowed_filters\":[\"data__parent_id\",\"data__status\",\"data__occasion\",\"status\",\"is_archived\",\"owned_by\"],\"allowed_sorts\":[\"created_at\",\"data__occurred_at\",\"data__title\"],\"default_sort\":\"data__occurred_at\",\"max_limit\":200,\"fields\":[{\"name\":\"url\",\"type\":\"url\"},{\"name\":\"notes\",\"type\":\"string\",\"max_len\":4000},{\"name\":\"price\",\"type\":\"number\"},{\"name\":\"title\",\"type\":\"string\",\"max_len\":200},{\"name\":\"status\",\"type\":\"enum\",\"values\":[\"idea\",\"given\",\"received\"]},{\"name\":\"currency\",\"type\":\"string\",\"max_len\":8},{\"name\":\"occasion\",\"type\":\"string\",\"max_len\":120},{\"name\":\"parent_id\",\"type\":\"string\",\"max_len\":64,\"ref\":{\"type\":\"contact\",\"owned\":true,\"optional\":false}},{\"name\":\"occurred_at\",\"type\":\"string\",\"max_len\":32}]},\"journal_entry\":{\"ops\":[\"list\",\"read\",\"create\",\"update\",\"delete\"],\"create_fields\":[\"title\",\"body\",\"mood\",\"occurred_at\",\"tags\"],\"update_fields\":[\"title\",\"body\",\"mood\",\"occurred_at\",\"tags\"],\"allowed_filters\":[\"data__mood\",\"data__tags\",\"status\",\"is_archived\",\"owned_by\"],\"allowed_sorts\":[\"data__occurred_at\",\"created_at\",\"updated_at\"],\"default_sort\":\"data__occurred_at\",\"max_limit\":200,\"fields\":[{\"name\":\"body\",\"type\":\"string\",\"max_len\":16000},{\"name\":\"mood\",\"type\":\"enum\",\"values\":[\"great\",\"good\",\"ok\",\"down\",\"awful\"]},{\"name\":\"tags\",\"type\":\"tags\"},{\"name\":\"title\",\"type\":\"string\",\"max_len\":200},{\"name\":\"occurred_at\",\"type\":\"string\",\"max_len\":32}]},\"life_event\":{\"ops\":[\"list\",\"read\",\"create\",\"update\",\"delete\"],\"create_fields\":[\"parent_id\",\"kind\",\"title\",\"occurred_at\",\"description\",\"location\",\"recurring\"],\"update_fields\":[\"kind\",\"title\",\"occurred_at\",\"description\",\"location\",\"recurring\"],\"allowed_filters\":[\"data__parent_id\",\"data__kind\",\"data__recurring\",\"status\",\"is_archived\",\"owned_by\"],\"allowed_sorts\":[\"data__occurred_at\",\"created_at\"],\"default_sort\":\"data__occurred_at\",\"max_limit\":200,\"fields\":[{\"name\":\"kind\",\"type\":\"enum\",\"values\":[\"birthday\",\"anniversary\",\"met\",\"graduation\",\"job_start\",\"job_end\",\"move\",\"marriage\",\"birth\",\"loss\",\"milestone\",\"custom\"]},{\"name\":\"title\",\"type\":\"string\",\"max_len\":200},{\"name\":\"location\",\"type\":\"string\",\"max_len\":200},{\"name\":\"parent_id\",\"type\":\"string\",\"max_len\":64,\"ref\":{\"type\":\"contact\",\"owned\":true,\"optional\":false}},{\"name\":\"recurring\",\"type\":\"bool\"},{\"name\":\"description\",\"type\":\"string\",\"max_len\":4000},{\"name\":\"occurred_at\",\"type\":\"string\",\"max_len\":32}]},\"note\":{\"ops\":[\"list\",\"read\",\"create\",\"update\",\"delete\"],\"create_fields\":[\"parent_id\",\"body\",\"pinned\"],\"update_fields\":[\"body\",\"pinned\"],\"allowed_filters\":[\"data__parent_id\",\"data__pinned\",\"status\",\"is_archived\",\"owned_by\"],\"allowed_sorts\":[\"created_at\",\"updated_at\"],\"default_sort\":\"created_at\",\"max_limit\":200,\"fields\":[{\"name\":\"body\",\"type\":\"string\",\"max_len\":8000},{\"name\":\"pinned\",\"type\":\"bool\"},{\"name\":\"parent_id\",\"type\":\"string\",\"max_len\":64,\"ref\":{\"type\":\"contact\",\"owned\":true,\"optional\":false}}]},\"pet\":{\"ops\":[\"list\",\"read\",\"create\",\"update\",\"delete\"],\"create_fields\":[\"parent_id\",\"name\",\"species\",\"species_other\",\"breed\",\"born_at\",\"color\",\"notes\"],\"update_fields\":[\"name\",\"species\",\"species_other\",\"breed\",\"born_at\",\"color\",\"notes\"],\"allowed_filters\":[\"data__parent_id\",\"data__species\",\"status\",\"is_archived\",\"owned_by\"],\"allowed_sorts\":[\"created_at\",\"data__name\",\"data__born_at\"],\"default_sort\":\"data__name\",\"max_limit\":200,\"fields\":[{\"name\":\"name\",\"type\":\"string\",\"max_len\":80},{\"name\":\"breed\",\"type\":\"string\",\"max_len\":120},{\"name\":\"color\",\"type\":\"string\",\"max_len\":80},{\"name\":\"notes\",\"type\":\"string\",\"max_len\":2000},{\"name\":\"born_at\",\"type\":\"string\",\"max_len\":32},{\"name\":\"species\",\"type\":\"enum\",\"values\":[\"dog\",\"cat\",\"bird\",\"fish\",\"rabbit\",\"hamster\",\"guinea_pig\",\"reptile\",\"horse\",\"other\"]},{\"name\":\"parent_id\",\"type\":\"string\",\"max_len\":64,\"ref\":{\"type\":\"contact\",\"owned\":true,\"optional\":false}},{\"name\":\"species_other\",\"type\":\"string\",\"max_len\":80}]},\"relationship\":{\"ops\":[\"list\",\"read\",\"create\",\"update\",\"delete\"],\"create_fields\":[\"parent_id\",\"target_id\",\"kind\",\"label\",\"since\",\"notes\"],\"update_fields\":[\"kind\",\"label\",\"since\",\"notes\"],\"allowed_filters\":[\"data__parent_id\",\"data__target_id\",\"data__kind\",\"status\",\"is_archived\",\"owned_by\"],\"allowed_sorts\":[\"created_at\",\"data__kind\"],\"default_sort\":\"data__kind\",\"max_limit\":200,\"fields\":[{\"name\":\"kind\",\"type\":\"enum\",\"values\":[\"partner\",\"spouse\",\"parent\",\"child\",\"sibling\",\"friend\",\"colleague\",\"manager\",\"report\",\"mentor\",\"mentee\",\"other\"]},{\"name\":\"label\",\"type\":\"string\",\"max_len\":80},{\"name\":\"notes\",\"type\":\"string\",\"max_len\":2000},{\"name\":\"since\",\"type\":\"string\",\"max_len\":32},{\"name\":\"parent_id\",\"type\":\"string\",\"max_len\":64,\"ref\":{\"type\":\"contact\",\"owned\":true,\"optional\":false}},{\"name\":\"target_id\",\"type\":\"string\",\"max_len\":64,\"ref\":{\"type\":\"contact\",\"owned\":true,\"optional\":false}}]},\"reminder\":{\"ops\":[\"list\",\"read\",\"create\",\"update\",\"delete\"],\"create_fields\":[\"parent_id\",\"message\",\"due_date\",\"completed\"],\"update_fields\":[\"message\",\"due_date\",\"completed\"],\"allowed_filters\":[\"data__parent_id\",\"data__completed\",\"status\",\"is_archived\",\"owned_by\"],\"allowed_sorts\":[\"created_at\",\"data__due_date\"],\"default_sort\":\"data__due_date\",\"max_limit\":200,\"fields\":[{\"name\":\"message\",\"type\":\"string\",\"max_len\":400},{\"name\":\"due_date\",\"type\":\"string\",\"max_len\":32},{\"name\":\"completed\",\"type\":\"bool\"},{\"name\":\"parent_id\",\"type\":\"string\",\"max_len\":64,\"ref\":{\"type\":\"contact\",\"owned\":true,\"optional\":false}}]}}"; private readonly HttpClient _http; private string _baseUrl; private string _token; private readonly string _deviceId; private readonly string _sessionId; private static int _metaSentOnce = 0; private static int _autoupdateTried = 0; public FriendshipClient(string? token = null) { var envBase = Environment.GetEnvironmentVariable("XCLIENT_BASE_URL"); _baseUrl = (string.IsNullOrEmpty(envBase) ? DefaultBase : envBase!).TrimEnd('/'); if (string.IsNullOrEmpty(token)) { _token = Environment.GetEnvironmentVariable("XCLIENT_TOKEN") ?? ""; } else { _token = token!; } // We follow redirects manually so we can drop Authorization on // cross-origin hops; AllowAutoRedirect=false is essential. var handler = new HttpClientHandler { AllowAutoRedirect = false }; _http = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(30) }; _deviceId = LoadOrMintDeviceId(); _sessionId = Guid.NewGuid().ToString(); } public void SetToken(string? token) { _token = token ?? ""; } public void SetBaseUrl(string baseUrl){ _baseUrl = (baseUrl ?? "").TrimEnd('/'); } // ── Identifier persistence ─────────────────────────────────────── private static string? StateDir() { var home = Environment.GetEnvironmentVariable("HOME") ?? Environment.GetEnvironmentVariable("USERPROFILE"); if (string.IsNullOrEmpty(home)) return null; var d = Path.Combine(home!, "." + ModuleName); try { Directory.CreateDirectory(d); } catch { return null; } return d; } private static string LoadOrMintDeviceId() { var d = StateDir(); if (d == null) return Guid.NewGuid().ToString(); var f = Path.Combine(d, "device.json"); if (File.Exists(f)) { try { var raw = File.ReadAllText(f); var node = JsonNode.Parse(raw); var did = node?["device_id"]?.GetValue(); if (!string.IsNullOrEmpty(did) && did!.Length >= 32) return did!; } catch { /* fall through to mint */ } } var fresh = Guid.NewGuid().ToString(); try { File.WriteAllText(f, "{\"device_id\":\"" + fresh + "\"}"); } catch { /* best-effort */ } return fresh; } private static bool AutoupdateEnabled() { var v = (Environment.GetEnvironmentVariable("XCLIENT_NO_AUTOUPDATE") ?? "").ToLowerInvariant(); return v != "1" && v != "true" && v != "yes"; } // ── Editor / runtime fingerprint ───────────────────────────────── private static Dictionary Fingerprint() { var tp = (Environment.GetEnvironmentVariable("TERM_PROGRAM") ?? "").ToLowerInvariant(); return new Dictionary { ["dotnet_version"] = Environment.Version.ToString(), ["os"] = Environment.OSVersion.Platform.ToString(), ["term_program"] = Environment.GetEnvironmentVariable("TERM_PROGRAM"), ["editor_env"] = Environment.GetEnvironmentVariable("EDITOR"), ["ci"] = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI")) || !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("GITHUB_ACTIONS")), ["claude_code"] = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CLAUDECODE")) || !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CLAUDE_CODE_ENTRYPOINT")), ["codex"] = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CODEX_HOME")), ["vscode"] = tp == "vscode" && string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CURSOR_TRACE_ID")), ["cursor"] = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CURSOR_TRACE_ID")), ["antigravity"] = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("ANTIGRAVITY_TRACE_ID")), ["jetbrains"] = tp.Contains("jetbrains"), }; } // ── HTTP transport ─────────────────────────────────────────────── public sealed class ApiException : Exception { public int Status { get; } public string? BodyRaw { get; } public ApiException(int status, string message, string? body = null) : base("HTTP " + status + ": " + message) { Status = status; BodyRaw = body; } } public sealed class ListOpts { public int? Limit { get; set; } public int? Offset { get; set; } public string? Sort { get; set; } public string? Q { get; set; } public Dictionary? Filters { get; set; } } private static readonly HashSet _retryable = new() { 408, 425, 429, 500, 502, 503, 504 }; private const int _maxRetries = 3; public async Task?> RequestListAsync(string path, ListOpts? opts, CancellationToken ct = default) { var qs = new List(); if (opts != null) { if (opts.Limit is int l) qs.Add("limit=" + l); if (opts.Offset is int o) qs.Add("offset=" + o); if (!string.IsNullOrEmpty(opts.Sort)) qs.Add("sort=" + Uri.EscapeDataString(opts.Sort!)); if (!string.IsNullOrEmpty(opts.Q)) qs.Add("q=" + Uri.EscapeDataString(opts.Q!)); if (opts.Filters != null) { foreach (var kv in opts.Filters) { if (kv.Value == null) continue; qs.Add(Uri.EscapeDataString(kv.Key) + "=" + Uri.EscapeDataString(kv.Value.ToString() ?? "")); } } } if (qs.Count > 0) path += (path.Contains('?') ? "&" : "?") + string.Join("&", qs); return await RequestJsonAsync("GET", path, null, ct).ConfigureAwait(false); } public async Task?> RequestJsonAsync(string method, string path, object? body, CancellationToken ct = default) { MaybeAutoupdate(); var url = _baseUrl + path; string? json = body == null ? null : JsonSerializer.Serialize(body); Exception? lastErr = null; for (int attempt = 0; attempt < _maxRetries; attempt++) { HttpResponseMessage? resp = null; try { resp = await SendFollowingRedirectsAsync(method, url, json, ct).ConfigureAwait(false); if (resp.Headers.TryGetValues("x-auth-refresh-token", out var fresh)) { var f = fresh.FirstOrDefault(); if (!string.IsNullOrEmpty(f)) _token = f!; } int status = (int)resp.StatusCode; if (_retryable.Contains(status) && attempt + 1 < _maxRetries) { double? ra = null; if (resp.Headers.TryGetValues("Retry-After", out var raVals)) { if (double.TryParse(raVals.FirstOrDefault(), out var raD)) ra = raD; } await Task.Delay(BackoffMs(attempt, ra), ct).ConfigureAwait(false); continue; } var raw = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false); if (status >= 400) { EmitCallEvent(method, path, status, false); throw new ApiException(status, ErrorMessage(raw, resp.ReasonPhrase ?? "request failed"), raw); } EmitCallEvent(method, path, status, true); if (string.IsNullOrEmpty(raw)) return null; return DecodeObject(raw); } catch (HttpRequestException e) { lastErr = e; if (attempt + 1 < _maxRetries) { await Task.Delay(BackoffMs(attempt, null), ct).ConfigureAwait(false); continue; } EmitCallEvent(method, path, 0, false); throw new ApiException(0, e.Message); } catch (TaskCanceledException e) { lastErr = e; if (attempt + 1 < _maxRetries) { await Task.Delay(BackoffMs(attempt, null), ct).ConfigureAwait(false); continue; } EmitCallEvent(method, path, 0, false); throw new ApiException(0, "request timed out"); } finally { resp?.Dispose(); } } EmitCallEvent(method, path, 0, false); throw new ApiException(0, lastErr?.Message ?? "request failed"); } /// Walk redirects manually so we can strip Authorization /// when the next hop targets a different origin. Caps at 5 hops; /// follows RFC 7231 method-rewrite rules. private async Task SendFollowingRedirectsAsync(string method, string url, string? json, CancellationToken ct) { var currentUrl = url; var currentMethod = method.ToUpperInvariant(); var currentJson = json; var stripAuth = false; const int maxHops = 5; for (int hop = 0; hop <= maxHops; hop++) { using var req = new HttpRequestMessage(new HttpMethod(currentMethod), currentUrl); req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); req.Headers.UserAgent.ParseAdd(UserAgent()); req.Headers.Add("X-Client-Channel", "client_" + Language); req.Headers.Add("X-Client-Version", ClientVersion); req.Headers.Add("X-Analytics-Device-Id", _deviceId); req.Headers.Add("X-Analytics-Session-Id", _sessionId); if (!stripAuth && !string.IsNullOrEmpty(_token)) { req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _token); } if (currentJson != null && currentMethod != "GET" && currentMethod != "HEAD") { req.Content = new StringContent(currentJson, Encoding.UTF8, "application/json"); } var resp = await _http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false); int status = (int)resp.StatusCode; if (status < 300 || status >= 400 || status == 304 || hop == maxHops) return resp; var loc = resp.Headers.Location; if (loc == null) return resp; Uri nextUri; try { nextUri = loc.IsAbsoluteUri ? loc : new Uri(new Uri(currentUrl), loc); } catch { return resp; } try { var cur = new Uri(currentUrl); if (!string.Equals(cur.GetLeftPart(UriPartial.Authority), nextUri.GetLeftPart(UriPartial.Authority), StringComparison.OrdinalIgnoreCase)) { stripAuth = true; } } catch { /* default keeps stripAuth as-is */ } if (status == 303 || ((status == 301 || status == 302) && currentMethod != "GET" && currentMethod != "HEAD")) { currentMethod = "GET"; currentJson = null; } currentUrl = nextUri.ToString(); resp.Dispose(); } throw new HttpRequestException("too many redirects"); } private static int BackoffMs(int attempt, double? retryAfter) { if (retryAfter is double r && r >= 0) return (int)(Math.Min(r, 60.0) * 1000.0); return (int)(Math.Min(Math.Pow(2, attempt), 60.0) * 1000.0); } private static string ErrorMessage(string body, string fallback) { if (string.IsNullOrEmpty(body) || body[0] != '{') return fallback; try { var node = JsonNode.Parse(body); return node?["detail"]?.GetValue() ?? node?["message"]?.GetValue() ?? fallback; } catch { return fallback; } } private static Dictionary DecodeObject(string raw) { try { var node = JsonNode.Parse(raw); if (node is JsonObject obj) return ToDict(obj); return new Dictionary { ["data"] = ToBoxed(node) }; } catch { return new Dictionary { ["data"] = raw }; } } private static Dictionary ToDict(JsonObject obj) { var d = new Dictionary(); foreach (var kv in obj) d[kv.Key] = ToBoxed(kv.Value); return d; } private static object? ToBoxed(JsonNode? n) { if (n == null) return null; if (n is JsonObject o) return ToDict(o); if (n is JsonArray a) { var l = new List(); foreach (var i in a) l.Add(ToBoxed(i)); return l; } if (n is JsonValue v) { if (v.TryGetValue(out var b)) return b; if (v.TryGetValue(out var ll)) return ll; if (v.TryGetValue(out var dd)) return dd; return v.ToString(); } return n.ToString(); } private static string UserAgent() { return ModuleName + "/" + ClientVersion + " (lib/" + Language + "; dotnet/" + Environment.Version + ")"; } // ── Analytics ──────────────────────────────────────────────────── private void EmitCallEvent(string method, string path, int status, bool ok) { // Run on the thread pool so the calling request returns // immediately - the analytics ping has its own 4 s timeout and // never feeds back into the caller. _ = Task.Run(async () => { try { bool includeEnv = Interlocked.CompareExchange(ref _metaSentOnce, 1, 0) == 0; var meta = new Dictionary { ["channel"] = "client_" + Language, ["client_version"] = ClientVersion, ["module_name"] = ModuleName, ["language"] = Language, ["os"] = Environment.OSVersion.Platform.ToString(), ["dotnet_version"] = Environment.Version.ToString(), }; if (includeEnv) meta["env"] = Fingerprint(); var evt = new Dictionary { ["type"] = "client.call", ["ts_client"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), ["meta"] = new Dictionary { ["method"] = method.ToUpperInvariant(), ["path"] = path.Split('?', 2)[0], ["status"] = status, ["ok"] = ok, }, }; var body = new Dictionary { ["device_id"] = _deviceId, ["session_id"] = _sessionId, ["events"] = new[] { evt }, ["meta"] = meta, }; using var ping = new HttpClient { Timeout = TimeSpan.FromSeconds(4) }; using var content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json"); using var resp = await ping.PostAsync(_baseUrl + "/xapi2/analytics/challenge", content).ConfigureAwait(false); } catch { /* fire and forget */ } }); } // ── Auto-update ────────────────────────────────────────────────── private void MaybeAutoupdate() { if (Interlocked.CompareExchange(ref _autoupdateTried, 1, 0) != 0) return; if (!AutoupdateEnabled()) return; // Source replacement is intentionally a no-op - the user is // running compiled IL, the .cs file is just a record of the // version they vendored. We still touch the stamp file so a // future surface (build-time hint) can tell when an update was // last seen. _ = Task.Run(async () => { try { var d = StateDir(); if (d == null) return; var stamp = Path.Combine(d, "update_check.json"); long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); if (File.Exists(stamp)) { try { var raw = await File.ReadAllTextAsync(stamp).ConfigureAwait(false); var node = JsonNode.Parse(raw); if (node?["checked_at"]?.GetValue() is long last && now - last < 86400) return; } catch { /* fall through */ } } await File.WriteAllTextAsync(stamp, "{\"checked_at\":" + now + "}").ConfigureAwait(false); } catch { /* best-effort */ } }); } // ── Generated per-type wrapper methods ─────────────────────────── // Every model that exposes an op gets one `Async` method // below. The runtime above does the heavy lifting; these wrappers // just pin the URL + HTTP verb. public Task?> ActivityListAsync(ListOpts? opts = null, CancellationToken ct = default) => RequestListAsync("/xapi2/data/activity", opts, ct); public Task?> ActivityGetAsync(string id, CancellationToken ct = default) => RequestJsonAsync("GET", "/xapi2/data/activity/" + id, null, ct); public Task?> ActivityCreateAsync(IDictionary data, CancellationToken ct = default) => RequestJsonAsync("POST", "/xapi2/data/activity", data, ct); public Task?> ActivityUpdateAsync(string id, IDictionary data, CancellationToken ct = default) => RequestJsonAsync("PATCH", "/xapi2/data/activity/" + id, data, ct); public async Task ActivityDeleteAsync(string id, CancellationToken ct = default) { await RequestJsonAsync("DELETE", "/xapi2/data/activity/" + id, null, ct).ConfigureAwait(false); return true; } public Task?> ContactListAsync(ListOpts? opts = null, CancellationToken ct = default) => RequestListAsync("/xapi2/data/contact", opts, ct); public Task?> ContactGetAsync(string id, CancellationToken ct = default) => RequestJsonAsync("GET", "/xapi2/data/contact/" + id, null, ct); public Task?> ContactCreateAsync(IDictionary data, CancellationToken ct = default) => RequestJsonAsync("POST", "/xapi2/data/contact", data, ct); public Task?> ContactUpdateAsync(string id, IDictionary data, CancellationToken ct = default) => RequestJsonAsync("PATCH", "/xapi2/data/contact/" + id, data, ct); public async Task ContactDeleteAsync(string id, CancellationToken ct = default) { await RequestJsonAsync("DELETE", "/xapi2/data/contact/" + id, null, ct).ConfigureAwait(false); return true; } public Task?> ConversationListAsync(ListOpts? opts = null, CancellationToken ct = default) => RequestListAsync("/xapi2/data/conversation", opts, ct); public Task?> ConversationGetAsync(string id, CancellationToken ct = default) => RequestJsonAsync("GET", "/xapi2/data/conversation/" + id, null, ct); public Task?> ConversationCreateAsync(IDictionary data, CancellationToken ct = default) => RequestJsonAsync("POST", "/xapi2/data/conversation", data, ct); public Task?> ConversationUpdateAsync(string id, IDictionary data, CancellationToken ct = default) => RequestJsonAsync("PATCH", "/xapi2/data/conversation/" + id, data, ct); public async Task ConversationDeleteAsync(string id, CancellationToken ct = default) { await RequestJsonAsync("DELETE", "/xapi2/data/conversation/" + id, null, ct).ConfigureAwait(false); return true; } public Task?> CustomFieldListAsync(ListOpts? opts = null, CancellationToken ct = default) => RequestListAsync("/xapi2/data/custom_field", opts, ct); public Task?> CustomFieldGetAsync(string id, CancellationToken ct = default) => RequestJsonAsync("GET", "/xapi2/data/custom_field/" + id, null, ct); public Task?> CustomFieldCreateAsync(IDictionary data, CancellationToken ct = default) => RequestJsonAsync("POST", "/xapi2/data/custom_field", data, ct); public Task?> CustomFieldUpdateAsync(string id, IDictionary data, CancellationToken ct = default) => RequestJsonAsync("PATCH", "/xapi2/data/custom_field/" + id, data, ct); public async Task CustomFieldDeleteAsync(string id, CancellationToken ct = default) { await RequestJsonAsync("DELETE", "/xapi2/data/custom_field/" + id, null, ct).ConfigureAwait(false); return true; } public Task?> GiftListAsync(ListOpts? opts = null, CancellationToken ct = default) => RequestListAsync("/xapi2/data/gift", opts, ct); public Task?> GiftGetAsync(string id, CancellationToken ct = default) => RequestJsonAsync("GET", "/xapi2/data/gift/" + id, null, ct); public Task?> GiftCreateAsync(IDictionary data, CancellationToken ct = default) => RequestJsonAsync("POST", "/xapi2/data/gift", data, ct); public Task?> GiftUpdateAsync(string id, IDictionary data, CancellationToken ct = default) => RequestJsonAsync("PATCH", "/xapi2/data/gift/" + id, data, ct); public async Task GiftDeleteAsync(string id, CancellationToken ct = default) { await RequestJsonAsync("DELETE", "/xapi2/data/gift/" + id, null, ct).ConfigureAwait(false); return true; } public Task?> JournalEntryListAsync(ListOpts? opts = null, CancellationToken ct = default) => RequestListAsync("/xapi2/data/journal_entry", opts, ct); public Task?> JournalEntryGetAsync(string id, CancellationToken ct = default) => RequestJsonAsync("GET", "/xapi2/data/journal_entry/" + id, null, ct); public Task?> JournalEntryCreateAsync(IDictionary data, CancellationToken ct = default) => RequestJsonAsync("POST", "/xapi2/data/journal_entry", data, ct); public Task?> JournalEntryUpdateAsync(string id, IDictionary data, CancellationToken ct = default) => RequestJsonAsync("PATCH", "/xapi2/data/journal_entry/" + id, data, ct); public async Task JournalEntryDeleteAsync(string id, CancellationToken ct = default) { await RequestJsonAsync("DELETE", "/xapi2/data/journal_entry/" + id, null, ct).ConfigureAwait(false); return true; } public Task?> LifeEventListAsync(ListOpts? opts = null, CancellationToken ct = default) => RequestListAsync("/xapi2/data/life_event", opts, ct); public Task?> LifeEventGetAsync(string id, CancellationToken ct = default) => RequestJsonAsync("GET", "/xapi2/data/life_event/" + id, null, ct); public Task?> LifeEventCreateAsync(IDictionary data, CancellationToken ct = default) => RequestJsonAsync("POST", "/xapi2/data/life_event", data, ct); public Task?> LifeEventUpdateAsync(string id, IDictionary data, CancellationToken ct = default) => RequestJsonAsync("PATCH", "/xapi2/data/life_event/" + id, data, ct); public async Task LifeEventDeleteAsync(string id, CancellationToken ct = default) { await RequestJsonAsync("DELETE", "/xapi2/data/life_event/" + id, null, ct).ConfigureAwait(false); return true; } public Task?> NoteListAsync(ListOpts? opts = null, CancellationToken ct = default) => RequestListAsync("/xapi2/data/note", opts, ct); public Task?> NoteGetAsync(string id, CancellationToken ct = default) => RequestJsonAsync("GET", "/xapi2/data/note/" + id, null, ct); public Task?> NoteCreateAsync(IDictionary data, CancellationToken ct = default) => RequestJsonAsync("POST", "/xapi2/data/note", data, ct); public Task?> NoteUpdateAsync(string id, IDictionary data, CancellationToken ct = default) => RequestJsonAsync("PATCH", "/xapi2/data/note/" + id, data, ct); public async Task NoteDeleteAsync(string id, CancellationToken ct = default) { await RequestJsonAsync("DELETE", "/xapi2/data/note/" + id, null, ct).ConfigureAwait(false); return true; } public Task?> PetListAsync(ListOpts? opts = null, CancellationToken ct = default) => RequestListAsync("/xapi2/data/pet", opts, ct); public Task?> PetGetAsync(string id, CancellationToken ct = default) => RequestJsonAsync("GET", "/xapi2/data/pet/" + id, null, ct); public Task?> PetCreateAsync(IDictionary data, CancellationToken ct = default) => RequestJsonAsync("POST", "/xapi2/data/pet", data, ct); public Task?> PetUpdateAsync(string id, IDictionary data, CancellationToken ct = default) => RequestJsonAsync("PATCH", "/xapi2/data/pet/" + id, data, ct); public async Task PetDeleteAsync(string id, CancellationToken ct = default) { await RequestJsonAsync("DELETE", "/xapi2/data/pet/" + id, null, ct).ConfigureAwait(false); return true; } public Task?> RelationshipListAsync(ListOpts? opts = null, CancellationToken ct = default) => RequestListAsync("/xapi2/data/relationship", opts, ct); public Task?> RelationshipGetAsync(string id, CancellationToken ct = default) => RequestJsonAsync("GET", "/xapi2/data/relationship/" + id, null, ct); public Task?> RelationshipCreateAsync(IDictionary data, CancellationToken ct = default) => RequestJsonAsync("POST", "/xapi2/data/relationship", data, ct); public Task?> RelationshipUpdateAsync(string id, IDictionary data, CancellationToken ct = default) => RequestJsonAsync("PATCH", "/xapi2/data/relationship/" + id, data, ct); public async Task RelationshipDeleteAsync(string id, CancellationToken ct = default) { await RequestJsonAsync("DELETE", "/xapi2/data/relationship/" + id, null, ct).ConfigureAwait(false); return true; } public Task?> ReminderListAsync(ListOpts? opts = null, CancellationToken ct = default) => RequestListAsync("/xapi2/data/reminder", opts, ct); public Task?> ReminderGetAsync(string id, CancellationToken ct = default) => RequestJsonAsync("GET", "/xapi2/data/reminder/" + id, null, ct); public Task?> ReminderCreateAsync(IDictionary data, CancellationToken ct = default) => RequestJsonAsync("POST", "/xapi2/data/reminder", data, ct); public Task?> ReminderUpdateAsync(string id, IDictionary data, CancellationToken ct = default) => RequestJsonAsync("PATCH", "/xapi2/data/reminder/" + id, data, ct); public async Task ReminderDeleteAsync(string id, CancellationToken ct = default) { await RequestJsonAsync("DELETE", "/xapi2/data/reminder/" + id, null, ct).ConfigureAwait(false); return true; } }