Add domestic vendor push infrastructure
This commit is contained in:
@@ -0,0 +1,571 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user