572 lines
23 KiB
C#
572 lines
23 KiB
C#
using System.Net;
|
|
using System.Net.Http.Headers;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using System.Collections.Concurrent;
|
|
using MiaoJiZhang.Domain.Entities;
|
|
|
|
namespace MiaoJiZhang.Api.Services;
|
|
|
|
public sealed record PushEnvelope(
|
|
string MessageId,
|
|
string Title,
|
|
string Body,
|
|
string Category,
|
|
string Action,
|
|
string? EntityId,
|
|
int TtlSeconds);
|
|
|
|
public sealed record PushSendResult(
|
|
bool Accepted,
|
|
bool Retryable,
|
|
bool InvalidToken,
|
|
string? ProviderMessageId = null,
|
|
string? ErrorCode = null,
|
|
string? ErrorMessage = null,
|
|
TimeSpan? RetryAfter = null);
|
|
|
|
public interface IPushProvider
|
|
{
|
|
string Provider { get; }
|
|
bool IsEnabled(string flavor);
|
|
IReadOnlyList<string> ConfigurationErrors(string flavor);
|
|
Task<PushSendResult> SendAsync(
|
|
string flavor,
|
|
string packageName,
|
|
string token,
|
|
PushEnvelope message,
|
|
CancellationToken ct);
|
|
}
|
|
|
|
public sealed class PushProviderRegistry(IEnumerable<IPushProvider> providers)
|
|
{
|
|
private readonly IReadOnlyDictionary<string, IPushProvider> items = providers
|
|
.ToDictionary(provider => provider.Provider, StringComparer.OrdinalIgnoreCase);
|
|
|
|
public IReadOnlyCollection<IPushProvider> All => items.Values.ToArray();
|
|
public IPushProvider? Find(string provider) => items.GetValueOrDefault(provider);
|
|
}
|
|
|
|
public sealed class OfficialPushProvider(
|
|
string provider,
|
|
IConfiguration configuration,
|
|
IHttpClientFactory httpClientFactory,
|
|
ILogger<OfficialPushProvider> logger) : IPushProvider
|
|
{
|
|
private readonly SemaphoreSlim tokenLock = new(1, 1);
|
|
private readonly ConcurrentDictionary<string, CachedAccessToken> accessTokens =
|
|
new(StringComparer.OrdinalIgnoreCase);
|
|
|
|
public string Provider { get; } = provider;
|
|
|
|
public bool IsEnabled(string flavor) =>
|
|
configuration.GetValue<bool>($"Push:Providers:{Provider}:{flavor}:Enabled");
|
|
|
|
public IReadOnlyList<string> ConfigurationErrors(string flavor)
|
|
{
|
|
if (!IsEnabled(flavor)) return ["disabled"];
|
|
var required = Provider switch
|
|
{
|
|
PushProviders.Huawei or PushProviders.Honor => new[] { "AppId", "AppSecret" },
|
|
PushProviders.Xiaomi => new[] { "AppSecret" },
|
|
PushProviders.Oppo => new[] { "AppKey", "MasterSecret" },
|
|
PushProviders.Vivo => new[] { "AppId", "AppKey", "AppSecret" },
|
|
PushProviders.Meizu => new[] { "AppId", "AppSecret" },
|
|
_ => [],
|
|
};
|
|
return required
|
|
.Where(key => string.IsNullOrWhiteSpace(Value(flavor, key)))
|
|
.Select(key => $"missing_{key.ToLowerInvariant()}")
|
|
.ToList();
|
|
}
|
|
|
|
public async Task<PushSendResult> SendAsync(
|
|
string flavor,
|
|
string packageName,
|
|
string token,
|
|
PushEnvelope message,
|
|
CancellationToken ct)
|
|
{
|
|
var errors = ConfigurationErrors(flavor);
|
|
if (errors.Count > 0)
|
|
return new(false, false, false, ErrorCode: "provider_not_configured",
|
|
ErrorMessage: string.Join(',', errors));
|
|
try
|
|
{
|
|
return Provider switch
|
|
{
|
|
PushProviders.Huawei => await SendHuaweiLike(flavor, packageName, token, message, false, ct),
|
|
PushProviders.Honor => await SendHuaweiLike(flavor, packageName, token, message, true, ct),
|
|
PushProviders.Xiaomi => await SendXiaomi(flavor, packageName, token, message, ct),
|
|
PushProviders.Oppo => await SendOppo(flavor, packageName, token, message, ct),
|
|
PushProviders.Vivo => await SendVivo(flavor, packageName, token, message, ct),
|
|
PushProviders.Meizu => await SendMeizu(flavor, packageName, token, message, ct),
|
|
_ => new(false, false, false, ErrorCode: "provider_unknown"),
|
|
};
|
|
}
|
|
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
|
{
|
|
return new(false, true, false, ErrorCode: "provider_timeout", ErrorMessage: "Provider request timed out");
|
|
}
|
|
catch (HttpRequestException exception)
|
|
{
|
|
logger.LogWarning(exception, "Push provider {Provider} request failed", Provider);
|
|
return new(false, true, false, ErrorCode: "provider_network_error", ErrorMessage: exception.Message);
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
logger.LogError(exception, "Push provider {Provider} failed unexpectedly", Provider);
|
|
return new(false, false, false, ErrorCode: "provider_internal_error", ErrorMessage: exception.Message);
|
|
}
|
|
}
|
|
|
|
private async Task<PushSendResult> SendHuaweiLike(
|
|
string flavor,
|
|
string packageName,
|
|
string token,
|
|
PushEnvelope message,
|
|
bool honor,
|
|
CancellationToken ct)
|
|
{
|
|
var accessToken = await GetOAuthToken(flavor, honor, ct);
|
|
if (accessToken.Result is not null) return accessToken.Result;
|
|
var appId = Value(flavor, "AppId")!;
|
|
var defaultUrl = honor
|
|
? $"https://push-api.cloud.hihonor.com/api/v1/{appId}/sendMessage"
|
|
: $"https://push-api.cloud.huawei.com/v1/{appId}/messages:send";
|
|
var url = Value(flavor, "SendUrl") ?? defaultUrl;
|
|
var intent = IntentUri(packageName, message);
|
|
object payload = honor
|
|
? new
|
|
{
|
|
message = new
|
|
{
|
|
notification = new { title = message.Title, body = message.Body },
|
|
android = new
|
|
{
|
|
ttl = $"{message.TtlSeconds}s",
|
|
data = PayloadJson(message),
|
|
notification = new
|
|
{
|
|
channel_id = Channel(flavor, message.Category),
|
|
click_action = new { type = 1, intent },
|
|
},
|
|
},
|
|
token = new[] { token },
|
|
},
|
|
}
|
|
: new
|
|
{
|
|
validate_only = false,
|
|
message = new
|
|
{
|
|
notification = new { title = message.Title, body = message.Body },
|
|
android = new
|
|
{
|
|
ttl = $"{message.TtlSeconds}s",
|
|
data = PayloadJson(message),
|
|
notification = new
|
|
{
|
|
channel_id = Channel(flavor, message.Category),
|
|
notify_id = StableNotificationId(message.MessageId),
|
|
click_action = new { type = 1, intent },
|
|
},
|
|
},
|
|
token = new[] { token },
|
|
},
|
|
};
|
|
using var request = JsonRequest(HttpMethod.Post, url, payload);
|
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken.Token);
|
|
return await Send(request, ct);
|
|
}
|
|
|
|
private async Task<(string? Token, PushSendResult? Result)> GetOAuthToken(
|
|
string flavor,
|
|
bool honor,
|
|
CancellationToken ct)
|
|
{
|
|
if (FreshAccessToken(flavor) is { } cached) return (cached, null);
|
|
await tokenLock.WaitAsync(ct);
|
|
try
|
|
{
|
|
if (FreshAccessToken(flavor) is { } lockedCached) return (lockedCached, null);
|
|
var defaultUrl = honor
|
|
? "https://iam.developer.hihonor.com/auth/token"
|
|
: "https://oauth-login.cloud.huawei.com/oauth2/v3/token";
|
|
var url = Value(flavor, "AuthUrl") ?? defaultUrl;
|
|
using var request = new HttpRequestMessage(HttpMethod.Post, url)
|
|
{
|
|
Content = new FormUrlEncodedContent(new Dictionary<string, string>
|
|
{
|
|
["grant_type"] = "client_credentials",
|
|
["client_id"] = Value(flavor, "AppId")!,
|
|
["client_secret"] = Value(flavor, "AppSecret")!,
|
|
}),
|
|
};
|
|
using var response = await Client().SendAsync(request, ct);
|
|
var body = await response.Content.ReadAsStringAsync(ct);
|
|
if (!response.IsSuccessStatusCode)
|
|
return (null, FromFailure(response, body));
|
|
using var json = JsonDocument.Parse(body);
|
|
if (!TryString(json.RootElement, out var value, "access_token", "accessToken", "token"))
|
|
return (null, new(false, false, false, ErrorCode: "provider_auth_invalid", ErrorMessage: Trim(body)));
|
|
var expires = TryInt(json.RootElement, "expires_in", "expiresIn") ?? 3600;
|
|
accessTokens[flavor] = new CachedAccessToken(
|
|
value!,
|
|
DateTime.UtcNow.AddSeconds(Math.Max(expires, 300)));
|
|
return (value, null);
|
|
}
|
|
finally
|
|
{
|
|
tokenLock.Release();
|
|
}
|
|
}
|
|
|
|
private async Task<PushSendResult> SendXiaomi(
|
|
string flavor,
|
|
string packageName,
|
|
string token,
|
|
PushEnvelope message,
|
|
CancellationToken ct)
|
|
{
|
|
var url = Value(flavor, "SendUrl") ?? "https://api.xmpush.xiaomi.com/v3/message/regid";
|
|
using var request = new HttpRequestMessage(HttpMethod.Post, url)
|
|
{
|
|
Content = new FormUrlEncodedContent(new Dictionary<string, string>
|
|
{
|
|
["registration_id"] = token,
|
|
["restricted_package_name"] = packageName,
|
|
["title"] = message.Title,
|
|
["description"] = message.Body,
|
|
["notify_id"] = StableNotificationId(message.MessageId).ToString(),
|
|
["time_to_live"] = (message.TtlSeconds * 1000L).ToString(),
|
|
["extra.notify_effect"] = "2",
|
|
["extra.intent_uri"] = IntentUri(packageName, message),
|
|
["extra.jz_payload"] = PayloadJson(message),
|
|
["extra.channel_id"] = Channel(flavor, message.Category),
|
|
}),
|
|
};
|
|
request.Headers.TryAddWithoutValidation("Authorization", $"key={Value(flavor, "AppSecret")}");
|
|
return await Send(request, ct);
|
|
}
|
|
|
|
private async Task<PushSendResult> SendOppo(
|
|
string flavor,
|
|
string packageName,
|
|
string token,
|
|
PushEnvelope message,
|
|
CancellationToken ct)
|
|
{
|
|
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString();
|
|
var appKey = Value(flavor, "AppKey")!;
|
|
var sign = Sha256Hex(appKey + timestamp + Value(flavor, "MasterSecret"));
|
|
var authUrl = Value(flavor, "AuthUrl") ?? "https://api.push.oppomobile.com/server/v1/auth";
|
|
using var authRequest = new HttpRequestMessage(HttpMethod.Post, authUrl)
|
|
{
|
|
Content = new FormUrlEncodedContent(new Dictionary<string, string>
|
|
{
|
|
["app_key"] = appKey,
|
|
["timestamp"] = timestamp,
|
|
["sign"] = sign,
|
|
}),
|
|
};
|
|
using var authResponse = await Client().SendAsync(authRequest, ct);
|
|
var authBody = await authResponse.Content.ReadAsStringAsync(ct);
|
|
if (!authResponse.IsSuccessStatusCode) return FromFailure(authResponse, authBody);
|
|
using var authJson = JsonDocument.Parse(authBody);
|
|
if (!TryNestedString(authJson.RootElement, out var authToken, "data", "auth_token") &&
|
|
!TryString(authJson.RootElement, out authToken, "auth_token", "authToken"))
|
|
return new(false, false, false, ErrorCode: "provider_auth_invalid", ErrorMessage: Trim(authBody));
|
|
|
|
var notification = JsonSerializer.Serialize(new
|
|
{
|
|
app_message_id = message.MessageId,
|
|
title = message.Title,
|
|
content = message.Body,
|
|
click_action_type = 1,
|
|
click_action_activity = $"{packageName}/com.nx.miaoji.MainActivity",
|
|
action_parameters = PayloadJson(message),
|
|
off_line = true,
|
|
off_line_ttl = message.TtlSeconds,
|
|
channel_id = Channel(flavor, message.Category),
|
|
});
|
|
var sendUrl = Value(flavor, "SendUrl") ??
|
|
"https://api.push.oppomobile.com/server/v1/message/notification/unicast";
|
|
using var request = new HttpRequestMessage(HttpMethod.Post, sendUrl)
|
|
{
|
|
Content = new FormUrlEncodedContent(new Dictionary<string, string>
|
|
{
|
|
["auth_token"] = authToken!,
|
|
["registration_id"] = token,
|
|
["message"] = notification,
|
|
}),
|
|
};
|
|
return await Send(request, ct);
|
|
}
|
|
|
|
private async Task<PushSendResult> SendVivo(
|
|
string flavor,
|
|
string packageName,
|
|
string token,
|
|
PushEnvelope message,
|
|
CancellationToken ct)
|
|
{
|
|
var appId = Value(flavor, "AppId")!;
|
|
var appKey = Value(flavor, "AppKey")!;
|
|
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString();
|
|
var sign = Md5Hex(appId + appKey + timestamp + Value(flavor, "AppSecret"));
|
|
var authUrl = Value(flavor, "AuthUrl") ?? "https://api-push.vivo.com.cn/message/auth";
|
|
using var authRequest = JsonRequest(HttpMethod.Post, authUrl, new
|
|
{
|
|
appId = int.TryParse(appId, out var id) ? id : 0,
|
|
appKey,
|
|
timestamp = long.Parse(timestamp),
|
|
sign,
|
|
});
|
|
using var authResponse = await Client().SendAsync(authRequest, ct);
|
|
var authBody = await authResponse.Content.ReadAsStringAsync(ct);
|
|
if (!authResponse.IsSuccessStatusCode) return FromFailure(authResponse, authBody);
|
|
using var authJson = JsonDocument.Parse(authBody);
|
|
if (!TryString(authJson.RootElement, out var authToken, "authToken", "auth_token"))
|
|
return new(false, false, false, ErrorCode: "provider_auth_invalid", ErrorMessage: Trim(authBody));
|
|
|
|
var sendUrl = Value(flavor, "SendUrl") ?? "https://api-push.vivo.com.cn/message/send";
|
|
using var request = JsonRequest(HttpMethod.Post, sendUrl, new
|
|
{
|
|
regId = token,
|
|
notifyType = 4,
|
|
title = message.Title,
|
|
content = message.Body,
|
|
timeToLive = message.TtlSeconds,
|
|
skipType = 3,
|
|
skipContent = IntentUri(packageName, message),
|
|
requestId = message.MessageId,
|
|
classification = message.Category == PushCategories.Operations ? 1 : 0,
|
|
clientCustomMap = new Dictionary<string, string> { ["jz_payload"] = PayloadJson(message) },
|
|
});
|
|
request.Headers.TryAddWithoutValidation("authToken", authToken);
|
|
return await Send(request, ct);
|
|
}
|
|
|
|
private async Task<PushSendResult> SendMeizu(
|
|
string flavor,
|
|
string packageName,
|
|
string token,
|
|
PushEnvelope message,
|
|
CancellationToken ct)
|
|
{
|
|
var appId = Value(flavor, "AppId")!;
|
|
var messageJson = JsonSerializer.Serialize(new
|
|
{
|
|
noticeBarInfo = new
|
|
{
|
|
title = message.Title,
|
|
content = message.Body,
|
|
noticeBarType = 0,
|
|
},
|
|
clickTypeInfo = new
|
|
{
|
|
clickType = 3,
|
|
parameters = new Dictionary<string, string> { ["jz_payload"] = PayloadJson(message) },
|
|
uri = IntentUri(packageName, message),
|
|
},
|
|
pushTimeInfo = new { offLine = true, validTime = message.TtlSeconds / 3600 },
|
|
advanceInfo = new { notifyId = StableNotificationId(message.MessageId) },
|
|
});
|
|
var values = new SortedDictionary<string, string>(StringComparer.Ordinal)
|
|
{
|
|
["appId"] = appId,
|
|
["pushIds"] = JsonSerializer.Serialize(new[] { token }),
|
|
["messageJson"] = messageJson,
|
|
};
|
|
var signSource = string.Concat(values.Select(item => item.Key + item.Value)) + Value(flavor, "AppSecret");
|
|
values["sign"] = Md5Hex(signSource);
|
|
var url = Value(flavor, "SendUrl") ??
|
|
"https://server-api-push.meizu.com/garcia/api/server/push/varnished/pushByPushId";
|
|
using var request = new HttpRequestMessage(HttpMethod.Post, url)
|
|
{
|
|
Content = new FormUrlEncodedContent(values),
|
|
};
|
|
return await Send(request, ct);
|
|
}
|
|
|
|
private async Task<PushSendResult> Send(HttpRequestMessage request, CancellationToken ct)
|
|
{
|
|
using var response = await Client().SendAsync(request, ct);
|
|
var body = await response.Content.ReadAsStringAsync(ct);
|
|
if (!response.IsSuccessStatusCode) return FromFailure(response, body);
|
|
string? providerId = null;
|
|
try
|
|
{
|
|
using var json = JsonDocument.Parse(body);
|
|
var businessFailure = FromBusinessFailure(json.RootElement, body);
|
|
if (businessFailure is not null) return businessFailure;
|
|
TryString(json.RootElement, out providerId,
|
|
"requestId", "request_id", "taskId", "msgId", "messageId", "code");
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
// Some providers return an empty or non-JSON success body.
|
|
}
|
|
return new(true, false, false, providerId);
|
|
}
|
|
|
|
private PushSendResult? FromBusinessFailure(JsonElement root, string body)
|
|
{
|
|
string? code = null;
|
|
var failed = Provider switch
|
|
{
|
|
PushProviders.Huawei or PushProviders.Honor =>
|
|
HasUnexpectedValue(root, ["0", "200", "80000000"], out code, "code"),
|
|
PushProviders.Xiaomi =>
|
|
HasUnexpectedValue(root, ["ok", "success"], out code, "result") ||
|
|
HasUnexpectedValue(root, ["0"], out code, "code"),
|
|
PushProviders.Oppo =>
|
|
HasUnexpectedValue(root, ["0"], out code, "code"),
|
|
PushProviders.Vivo =>
|
|
HasUnexpectedValue(root, ["0"], out code, "result", "code"),
|
|
PushProviders.Meizu =>
|
|
HasUnexpectedValue(root, ["200"], out code, "code"),
|
|
_ => false,
|
|
};
|
|
if (!failed && TryString(root, out var error, "error", "error_description") &&
|
|
!string.IsNullOrWhiteSpace(error))
|
|
{
|
|
code = error;
|
|
failed = true;
|
|
}
|
|
if (!failed) return null;
|
|
|
|
var normalized = body.ToLowerInvariant();
|
|
var retryable = normalized.Contains("rate limit") || normalized.Contains("too many") ||
|
|
normalized.Contains("frequency") || normalized.Contains("system busy") ||
|
|
normalized.Contains("try again");
|
|
return new PushSendResult(
|
|
false,
|
|
retryable,
|
|
LooksLikeInvalidToken(normalized),
|
|
ErrorCode: $"provider_{NormalizeCode(code)}",
|
|
ErrorMessage: Trim(body));
|
|
}
|
|
|
|
private static PushSendResult FromFailure(HttpResponseMessage response, string body)
|
|
{
|
|
var normalized = body.ToLowerInvariant();
|
|
var retryable = response.StatusCode == HttpStatusCode.TooManyRequests ||
|
|
(int)response.StatusCode >= 500;
|
|
TimeSpan? retryAfter = response.Headers.RetryAfter?.Delta;
|
|
return new(false, retryable, LooksLikeInvalidToken(normalized),
|
|
ErrorCode: $"http_{(int)response.StatusCode}",
|
|
ErrorMessage: Trim(body), RetryAfter: retryAfter);
|
|
}
|
|
|
|
private string? FreshAccessToken(string flavor) =>
|
|
accessTokens.TryGetValue(flavor, out var cached) &&
|
|
cached.ExpiresAt > DateTime.UtcNow.AddMinutes(2)
|
|
? cached.Token
|
|
: null;
|
|
|
|
private HttpClient Client() => httpClientFactory.CreateClient("PushProviders");
|
|
private string? Value(string flavor, string key) =>
|
|
configuration[$"Push:Providers:{Provider}:{flavor}:{key}"];
|
|
private string Channel(string flavor, string category) =>
|
|
Value(flavor, $"Channels:{category}") ?? $"jizhi_{category}";
|
|
|
|
private static HttpRequestMessage JsonRequest(HttpMethod method, string url, object body) => new(method, url)
|
|
{
|
|
Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json"),
|
|
};
|
|
|
|
private static string PayloadJson(PushEnvelope message) => JsonSerializer.Serialize(new
|
|
{
|
|
v = 1,
|
|
messageId = message.MessageId,
|
|
category = message.Category,
|
|
action = message.Action,
|
|
entityId = message.EntityId,
|
|
});
|
|
|
|
private static string IntentUri(string packageName, PushEnvelope message)
|
|
{
|
|
var entity = message.EntityId is null ? "" : $"&entityId={Uri.EscapeDataString(message.EntityId)}";
|
|
return $"miaoji://push/open?messageId={Uri.EscapeDataString(message.MessageId)}" +
|
|
$"&category={Uri.EscapeDataString(message.Category)}&action={Uri.EscapeDataString(message.Action)}{entity}";
|
|
}
|
|
|
|
private static int StableNotificationId(string messageId)
|
|
{
|
|
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(messageId));
|
|
return BitConverter.ToInt32(bytes, 0) & int.MaxValue;
|
|
}
|
|
|
|
private static string Sha256Hex(string value) =>
|
|
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant();
|
|
private static string Md5Hex(string value) =>
|
|
Convert.ToHexString(MD5.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant();
|
|
private static string Trim(string value) => value.Length <= 400 ? value : value[..400];
|
|
|
|
private static bool HasUnexpectedValue(
|
|
JsonElement root,
|
|
string[] accepted,
|
|
out string? value,
|
|
params string[] names)
|
|
{
|
|
foreach (var name in names)
|
|
{
|
|
if (!TryString(root, out value, name)) continue;
|
|
return !accepted.Contains(value!);
|
|
}
|
|
value = null;
|
|
return false;
|
|
}
|
|
|
|
private static bool LooksLikeInvalidToken(string normalized) =>
|
|
normalized.Contains("invalid token") || normalized.Contains("invalid reg") ||
|
|
normalized.Contains("registration_id_invalid") || normalized.Contains("target invalid") ||
|
|
normalized.Contains("pushid") && normalized.Contains("invalid");
|
|
|
|
private static string NormalizeCode(string? value)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(value)) return "rejected";
|
|
var normalized = new string(value.Where(character => char.IsLetterOrDigit(character) || character == '_')
|
|
.Take(48).ToArray());
|
|
return string.IsNullOrEmpty(normalized) ? "rejected" : normalized.ToLowerInvariant();
|
|
}
|
|
|
|
private static bool TryString(JsonElement root, out string? value, params string[] names)
|
|
{
|
|
foreach (var name in names)
|
|
{
|
|
if (root.ValueKind == JsonValueKind.Object && root.TryGetProperty(name, out var element))
|
|
{
|
|
value = element.ValueKind == JsonValueKind.String ? element.GetString() : element.ToString();
|
|
if (!string.IsNullOrWhiteSpace(value)) return true;
|
|
}
|
|
}
|
|
value = null;
|
|
return false;
|
|
}
|
|
|
|
private static bool TryNestedString(JsonElement root, out string? value, string parent, string child)
|
|
{
|
|
if (root.ValueKind == JsonValueKind.Object && root.TryGetProperty(parent, out var nested))
|
|
return TryString(nested, out value, child);
|
|
value = null;
|
|
return false;
|
|
}
|
|
|
|
private static int? TryInt(JsonElement root, params string[] names)
|
|
{
|
|
foreach (var name in names)
|
|
{
|
|
if (!root.TryGetProperty(name, out var value)) continue;
|
|
if (value.TryGetInt32(out var parsed)) return parsed;
|
|
if (int.TryParse(value.ToString(), out parsed)) return parsed;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private sealed record CachedAccessToken(string Token, DateTime ExpiresAt);
|
|
}
|