From 5fec5b9db430110fc205729fee013828234dd3bb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 4 May 2026 07:56:21 +0000 Subject: [PATCH 1/2] Fix Spond API login: switch to api.spond.com/core/v1 endpoint and add 2FA/OTP support Agent-Logs-Url: https://github.com/ArizonaGreenTea05/Spond.API/sessions/6b23ee56-f413-4385-8c96-c05348425b52 Co-authored-by: ArizonaGreenTea05 <84347307+ArizonaGreenTea05@users.noreply.github.com> --- Spond.API.Test/Program.cs | 10 ++- Spond.API/Models/CommonData.Core.V1.cs | 89 +++++++++++++++++++++ Spond.API/Services/SpondClient.cs | 104 ++++++++++++++++++++++--- 3 files changed, 189 insertions(+), 14 deletions(-) create mode 100644 Spond.API/Models/CommonData.Core.V1.cs diff --git a/Spond.API.Test/Program.cs b/Spond.API.Test/Program.cs index 4de9c52..aa64848 100644 --- a/Spond.API.Test/Program.cs +++ b/Spond.API.Test/Program.cs @@ -256,8 +256,14 @@ private static async Task PrintGroups() public static async Task Login(string emailPhone, string password) { - if (await SpondClient.LoginWithEmail(emailPhone, password)) return true; - if (await SpondClient.LoginWithPhoneNumber(emailPhone, password)) return true; + static async Task OtpCallback(string maskedPhone) + { + Console.Write($"Enter the one-time password sent to {maskedPhone}: "); + return Console.ReadLine() ?? string.Empty; + } + + if (await SpondClient.LoginWithEmail(emailPhone, password, OtpCallback)) return true; + if (await SpondClient.LoginWithPhoneNumber(emailPhone, password, OtpCallback)) return true; return false; } } diff --git a/Spond.API/Models/CommonData.Core.V1.cs b/Spond.API/Models/CommonData.Core.V1.cs new file mode 100644 index 0000000..e345dea --- /dev/null +++ b/Spond.API/Models/CommonData.Core.V1.cs @@ -0,0 +1,89 @@ +using Spond.API.Extensions; +using Spond.API.Interfaces; + +namespace Spond.API.Models; + +/// +/// Implementation of ICommonData for the current Spond API (core/v1). +/// Provides API endpoints and URL construction for the active Spond API. +/// +internal class CommonData_Core_V1 : ICommonData +{ + /// + public string LoginTokenPropertyName => "loginToken"; + /// + public string BaseUrl => "https://api.spond.com/"; + /// + public string LoginUrl => "core/v1/login"; + /// + public string UserUrl => "core/v1/profile"; + /// + public string GroupsUrl => "core/v1/groups"; + + /// + /// Constructs the events URL with query parameters. + /// + /// List of query parameters. + /// The complete events URL with query string. + private static string GetEventsUrl(List parameters) + { + return $"core/v1/sponds{(parameters.Count > 0 ? "?" : string.Empty)}{string.Join('&', parameters)}"; + } + + /// + /// Builds the list of query parameters for event requests. + /// + /// The minimum end time for events. + /// The maximum end time for events. + /// Whether to include event comments. + /// Whether to include hidden events. + /// Whether to add profile information. + /// Whether to include scheduled events. + /// The sort order for events. + /// The maximum number of events to retrieve. + /// Optional group ID filter. + /// Optional subgroup ID filter. + /// A list of formatted query parameter strings. + private static List GetEventsParameters(DateTime minEndTime, DateTime maxEndTime, bool? includeComments, bool? includeHidden, bool? addProfileInfo, bool? scheduled, Enums.Order? order, int? max, string? groupId, string? subGroupId) + { + var parameters = new List + { + $"minEndTimestamp={minEndTime.ToIso8601(true)}", + $"maxEndTimestamp={maxEndTime.ToIso8601(true)}", + $"max={max ?? (int)Math.Max(1, Math.Round((maxEndTime - minEndTime).TotalDays * 5))}", + $"order={order switch + { + Enums.Order.Ascending => "asc", + Enums.Order.Descending => "desc", + _ => "asc" + }}" + }; + + if (includeComments is not null) parameters.Add($"includeComments={includeComments.ToString()?.ToLower()}"); + if (includeHidden is not null) parameters.Add($"includeHidden={includeHidden.ToString()?.ToLower()}"); + if (addProfileInfo is not null) parameters.Add($"addProfileInfo={addProfileInfo.ToString()?.ToLower()}"); + if (scheduled is not null) parameters.Add($"scheduled={scheduled.ToString()?.ToLower()}"); + if (groupId is not null) parameters.Add($"groupId={groupId}"); + if (subGroupId is not null) parameters.Add($"subGroupId={subGroupId}"); + + return parameters; + } + + /// + public string GetEventsUrl(DateTime minEndTime, DateTime maxEndTime, bool? includeComments, bool? includeHidden, bool? addProfileInfo, bool? scheduled, Enums.Order? order, int? max) + { + return GetEventsUrl(GetEventsParameters(minEndTime, maxEndTime, includeComments, includeHidden, addProfileInfo, scheduled, order, max, null, null)); + } + + /// + public string GetEventsUrl(string groupId, DateTime minEndTime, DateTime maxEndTime, bool? includeComments, bool? includeHidden, bool? addProfileInfo, bool? scheduled, Enums.Order? order, int? max) + { + return GetEventsUrl(GetEventsParameters(minEndTime, maxEndTime, includeComments, includeHidden, addProfileInfo, scheduled, order, max, groupId, null)); + } + + /// + public string GetEventsUrl(string groupId, string subGroupId, DateTime minEndTime, DateTime maxEndTime, bool? includeComments, bool? includeHidden, bool? addProfileInfo, bool? scheduled, Enums.Order? order, int? max) + { + return GetEventsUrl(GetEventsParameters(minEndTime, maxEndTime, includeComments, includeHidden, addProfileInfo, scheduled, order, max, groupId, subGroupId)); + } +} diff --git a/Spond.API/Services/SpondClient.cs b/Spond.API/Services/SpondClient.cs index 95f2447..2c6f9ac 100644 --- a/Spond.API/Services/SpondClient.cs +++ b/Spond.API/Services/SpondClient.cs @@ -3,6 +3,7 @@ using Spond.API.Models; using System.Net; using System.Net.Http.Json; +using System.Text.Json; using Newtonsoft.Json; using static Spond.API.Enums; using JsonDocument = System.Text.Json.JsonDocument; @@ -22,59 +23,138 @@ public class SpondClient /// /// Initializes a new instance of the class. /// - /// Optional common data configuration. If null, defaults to CommonData_2_1. + /// Optional common data configuration. If null, defaults to CommonData_Core_V1. /// Optional logger for logging client operations. public SpondClient(ICommonData? commonData = null, ILogger? logger = null) { - _commonData = commonData ?? new CommonData_2_1(); + _commonData = commonData ?? new CommonData_Core_V1(); _client = new HttpClient(new HttpClientHandler { CookieContainer = new CookieContainer() }) { BaseAddress = new Uri(_commonData.BaseUrl) }; _logger = logger; } /// /// Authenticates with the Spond API using an email address and password. + /// When the account requires two-factor authentication, the + /// is invoked with the masked phone number that received the one-time code. + /// The callback must return the OTP entered by the user. + /// If is null and 2FA is required, the login fails. /// /// The user's email address. /// The user's password. + /// + /// Optional async callback invoked when a one-time password is needed. + /// Receives the masked phone number (e.g. "****12") and must return the OTP code. + /// /// True if login was successful, false otherwise. - public async Task LoginWithEmail(string email, string password) + public async Task LoginWithEmail(string email, string password, Func>? otpCallback = null) { var loginPayload = new { email, password }; - return await Login(loginPayload); + return await Login(loginPayload, otpCallback); } /// /// Authenticates with the Spond API using a phone number and password. + /// When the account requires two-factor authentication, the + /// is invoked with the masked phone number that received the one-time code. + /// The callback must return the OTP entered by the user. + /// If is null and 2FA is required, the login fails. /// /// The user's phone number. /// The user's password. + /// + /// Optional async callback invoked when a one-time password is needed. + /// Receives the masked phone number (e.g. "****12") and must return the OTP code. + /// /// True if login was successful, false otherwise. - public async Task LoginWithPhoneNumber(string phoneNumber, string password) + public async Task LoginWithPhoneNumber(string phoneNumber, string password, Func>? otpCallback = null) { var loginPayload = new { phoneNumber, password }; - return await Login(loginPayload); + return await Login(loginPayload, otpCallback); } /// /// Internal method to handle the login process with different payload types. + /// Supports the two-factor authentication flow: if the API responds with a + /// temporary token and a masked phone number instead of a login token, the + /// is used to obtain the one-time password and + /// a second verification request is sent to complete authentication. /// /// The type of the login payload (email or phone number). /// The login credentials payload. + /// + /// Optional async callback invoked when 2FA is required. + /// Receives the masked phone number and must return the OTP code. + /// /// True if login was successful, false otherwise. - private async Task Login(T loginPayload) + private async Task Login(T loginPayload, Func>? otpCallback) { var loginResp = await _client.PostAsJsonAsync(_commonData.LoginUrl, loginPayload); if (!loginResp.IsSuccessStatusCode) { - _logger?.LogError($"Error logging in: {loginResp.StatusCode} - {loginResp.Content.ReadAsStringAsync()}"); + _logger?.LogError("Error logging in: {StatusCode}", loginResp.StatusCode); return false; } + var loginJson = await loginResp.Content.ReadAsStringAsync(); using var doc = JsonDocument.Parse(loginJson); - var loginToken = doc.RootElement.GetProperty(_commonData.LoginTokenPropertyName).GetString(); - _client.DefaultRequestHeaders.Authorization = - new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", loginToken); - return true; + var root = doc.RootElement; + + // Happy path: direct login token returned (no 2FA). + if (root.TryGetProperty(_commonData.LoginTokenPropertyName, out var tokenElement)) + { + var loginToken = tokenElement.GetString(); + _client.DefaultRequestHeaders.Authorization = + new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", loginToken); + return true; + } + + // 2FA path: API returned a temporary token and the masked destination phone number. + if (root.TryGetProperty("token", out var tempTokenElement) && + root.TryGetProperty("phoneNumber", out var phoneElement)) + { + var tempToken = tempTokenElement.GetString(); + var maskedPhone = phoneElement.GetString() ?? string.Empty; + + if (otpCallback is null) + { + _logger?.LogError( + "Login requires a one-time password sent to {Phone}. " + + "Provide an otpCallback to handle two-factor authentication.", + maskedPhone); + return false; + } + + var otpCode = await otpCallback(maskedPhone); + if (string.IsNullOrWhiteSpace(otpCode)) + { + _logger?.LogError("OTP callback returned an empty code."); + return false; + } + + var otpPayload = new { code = otpCode, token = tempToken }; + var otpResp = await _client.PostAsJsonAsync(_commonData.LoginUrl, otpPayload); + if (!otpResp.IsSuccessStatusCode) + { + _logger?.LogError("OTP verification failed: {StatusCode}", otpResp.StatusCode); + return false; + } + + var otpJson = await otpResp.Content.ReadAsStringAsync(); + using var otpDoc = JsonDocument.Parse(otpJson); + if (!otpDoc.RootElement.TryGetProperty(_commonData.LoginTokenPropertyName, out var finalTokenElement)) + { + _logger?.LogError("OTP verification response did not contain a login token."); + return false; + } + + var finalToken = finalTokenElement.GetString(); + _client.DefaultRequestHeaders.Authorization = + new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", finalToken); + return true; + } + + _logger?.LogError("Unexpected login response: {Response}", loginJson); + return false; } /// From 9a7359817b09278c05db296bda1379ce7026b091 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 4 May 2026 07:58:09 +0000 Subject: [PATCH 2/2] Address code review: null checks for tokens, fix OtpCallback async warning Agent-Logs-Url: https://github.com/ArizonaGreenTea05/Spond.API/sessions/6b23ee56-f413-4385-8c96-c05348425b52 Co-authored-by: ArizonaGreenTea05 <84347307+ArizonaGreenTea05@users.noreply.github.com> --- Spond.API.Test/Program.cs | 4 ++-- Spond.API/Services/SpondClient.cs | 10 ++++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/Spond.API.Test/Program.cs b/Spond.API.Test/Program.cs index aa64848..186fff1 100644 --- a/Spond.API.Test/Program.cs +++ b/Spond.API.Test/Program.cs @@ -256,10 +256,10 @@ private static async Task PrintGroups() public static async Task Login(string emailPhone, string password) { - static async Task OtpCallback(string maskedPhone) + static Task OtpCallback(string maskedPhone) { Console.Write($"Enter the one-time password sent to {maskedPhone}: "); - return Console.ReadLine() ?? string.Empty; + return Task.FromResult(Console.ReadLine() ?? string.Empty); } if (await SpondClient.LoginWithEmail(emailPhone, password, OtpCallback)) return true; diff --git a/Spond.API/Services/SpondClient.cs b/Spond.API/Services/SpondClient.cs index 2c6f9ac..d9d5146 100644 --- a/Spond.API/Services/SpondClient.cs +++ b/Spond.API/Services/SpondClient.cs @@ -103,6 +103,11 @@ private async Task Login(T loginPayload, Func>? ot if (root.TryGetProperty(_commonData.LoginTokenPropertyName, out var tokenElement)) { var loginToken = tokenElement.GetString(); + if (string.IsNullOrEmpty(loginToken)) + { + _logger?.LogError("Login response contained an empty login token."); + return false; + } _client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", loginToken); return true; @@ -148,6 +153,11 @@ private async Task Login(T loginPayload, Func>? ot } var finalToken = finalTokenElement.GetString(); + if (string.IsNullOrEmpty(finalToken)) + { + _logger?.LogError("OTP verification response contained an empty login token."); + return false; + } _client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", finalToken); return true;