From 75d5bdafb91dea6235175401b20a5901594e36b7 Mon Sep 17 00:00:00 2001 From: davidwei Date: Sat, 17 Jan 2026 16:43:59 +1300 Subject: [PATCH 1/2] Add Fanfou integration with OAuth authentication and sync capabilities --- .../Controllers/FanfouAuthController.cs | 162 +++++++++ .../FanfouUserAccountController.cs | 147 ++++++++ src/HappyNotes.Api/ServiceExtensions.cs | 4 + src/HappyNotes.Common/Constants.cs | 4 +- src/HappyNotes.Common/Enums/FanfouSyncType.cs | 33 ++ .../Enums/FanfouUserAccountStatus.cs | 19 + src/HappyNotes.Dto/FanfouUserAccountDto.cs | 45 +++ src/HappyNotes.Entities/FanfouUserAccount.cs | 38 ++ src/HappyNotes.Entities/Note.cs | 42 +++ .../FanfouStatusService.cs | 335 ++++++++++++++++++ .../FanfouSyncNoteService.cs | 299 ++++++++++++++++ .../FanfouUserAccountCacheService.cs | 44 +++ .../MastodonTootService.cs | 15 +- .../Extensions/ServiceCollectionExtensions.cs | 1 + .../SyncQueue/Handlers/FanfouSyncHandler.cs | 273 ++++++++++++++ .../SyncQueue/Models/FanfouSyncPayload.cs | 11 + src/HappyNotes.Services/TextToImageService.cs | 47 +++ .../interfaces/IFanfouStatusService.cs | 38 ++ .../IFanfouUserAccountCacheService.cs | 10 + .../interfaces/ITextToImageService.cs | 14 + .../FanfouSyncHandlerTests.cs | 329 +++++++++++++++++ .../FanfouSyncNoteServiceTests.cs | 213 +++++++++++ 22 files changed, 2113 insertions(+), 10 deletions(-) create mode 100644 src/HappyNotes.Api/Controllers/FanfouAuthController.cs create mode 100644 src/HappyNotes.Api/Controllers/FanfouUserAccountController.cs create mode 100644 src/HappyNotes.Common/Enums/FanfouSyncType.cs create mode 100644 src/HappyNotes.Common/Enums/FanfouUserAccountStatus.cs create mode 100644 src/HappyNotes.Dto/FanfouUserAccountDto.cs create mode 100644 src/HappyNotes.Entities/FanfouUserAccount.cs create mode 100644 src/HappyNotes.Services/FanfouStatusService.cs create mode 100644 src/HappyNotes.Services/FanfouSyncNoteService.cs create mode 100644 src/HappyNotes.Services/FanfouUserAccountCacheService.cs create mode 100644 src/HappyNotes.Services/SyncQueue/Handlers/FanfouSyncHandler.cs create mode 100644 src/HappyNotes.Services/SyncQueue/Models/FanfouSyncPayload.cs create mode 100644 src/HappyNotes.Services/TextToImageService.cs create mode 100644 src/HappyNotes.Services/interfaces/IFanfouStatusService.cs create mode 100644 src/HappyNotes.Services/interfaces/IFanfouUserAccountCacheService.cs create mode 100644 src/HappyNotes.Services/interfaces/ITextToImageService.cs create mode 100644 tests/HappyNotes.Services.Tests/FanfouSyncHandlerTests.cs create mode 100644 tests/HappyNotes.Services.Tests/FanfouSyncNoteServiceTests.cs diff --git a/src/HappyNotes.Api/Controllers/FanfouAuthController.cs b/src/HappyNotes.Api/Controllers/FanfouAuthController.cs new file mode 100644 index 0000000..5bdf9af --- /dev/null +++ b/src/HappyNotes.Api/Controllers/FanfouAuthController.cs @@ -0,0 +1,162 @@ +using Api.Framework; +using Api.Framework.Helper; +using Api.Framework.Models; +using Api.Framework.Result; +using HappyNotes.Entities; +using HappyNotes.Services.interfaces; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Newtonsoft.Json; +using System.Web; + +namespace HappyNotes.Api.Controllers; + +[Authorize] +public class FanfouAuthController( + IRepositoryBase fanfouUserAccountRepository, + ICurrentUser currentUser, + ILogger logger, + IOptions jwtConfig, + IGeneralMemoryCacheService generalMemoryCacheService) : BaseController +{ + private readonly JwtConfig _jwtConfig = jwtConfig.Value; + + [HttpPost] + public ApiResult SetState([FromHeader(Name = "X-State")] string state) + { + if (string.IsNullOrEmpty(state)) + { + return Fail("State cannot be empty"); + } + + var cacheId = state; + generalMemoryCacheService.Set(cacheId, currentUser.Id); + + logger.LogInformation("State set for user {UserId}", currentUser.Id); + + return Success(); + } + + [HttpGet] + [AllowAnonymous] + public async Task Callback(string oauth_token, string oauth_verifier, string state) + { + logger.LogInformation("Starting Fanfou OAuth callback. State: {State}", state); + + try + { + if (string.IsNullOrEmpty(state)) + { + logger.LogError("Empty state received in OAuth callback"); + throw new Exception("Unexpected empty state received"); + } + + // Validate state + var userId = generalMemoryCacheService.Get(state); + if (userId == 0) + { + logger.LogError("Invalid state {State} received", state); + throw new Exception("Invalid state"); + } + + logger.LogDebug("OAuth callback for user {UserId}", userId); + + // Get request token and secret from cache (stored during request-token phase) + var requestTokenKey = $"fanfou_request_token_{state}"; + var requestTokenData = generalMemoryCacheService.Get>(requestTokenKey); + if (requestTokenData == null) + { + logger.LogError("No request token data found for state {State}", state); + throw new Exception("No request token data found"); + } + + var requestToken = requestTokenData["oauth_token"]; + var requestTokenSecret = requestTokenData["oauth_token_secret"]; + var consumerKey = requestTokenData["consumer_key"]; + var consumerSecret = TextEncryptionHelper.Decrypt(requestTokenData["consumer_secret"], _jwtConfig.SymmetricSecurityKey); + + // Exchange request token for access token + var (accessToken, accessTokenSecret, username) = await ExchangeRequestTokenForAccessToken( + consumerKey, consumerSecret, requestToken, requestTokenSecret, oauth_verifier); + + if (string.IsNullOrEmpty(accessToken)) + { + return Fail("Failed to obtain access token from Fanfou"); + } + + // Check if account already exists + var existingAccount = await fanfouUserAccountRepository.GetFirstOrDefaultAsync( + a => a.UserId == userId && a.Username == username); + + if (existingAccount != null) + { + // Update existing account + existingAccount.AccessToken = TextEncryptionHelper.Encrypt(accessToken, _jwtConfig.SymmetricSecurityKey); + existingAccount.AccessTokenSecret = TextEncryptionHelper.Encrypt(accessTokenSecret, _jwtConfig.SymmetricSecurityKey); + existingAccount.Status = Common.Enums.FanfouUserAccountStatus.Active; + await fanfouUserAccountRepository.UpdateAsync(existingAccount); + + logger.LogInformation("Updated existing Fanfou user account for user {UserId}, username {Username}", + userId, username); + } + else + { + // Create new account + var fanfouUserAccount = new FanfouUserAccount + { + UserId = userId, + Username = username, + ConsumerKey = TextEncryptionHelper.Encrypt(consumerKey, _jwtConfig.SymmetricSecurityKey), + ConsumerSecret = TextEncryptionHelper.Encrypt(consumerSecret, _jwtConfig.SymmetricSecurityKey), + AccessToken = TextEncryptionHelper.Encrypt(accessToken, _jwtConfig.SymmetricSecurityKey), + AccessTokenSecret = TextEncryptionHelper.Encrypt(accessTokenSecret, _jwtConfig.SymmetricSecurityKey), + SyncType = Common.Enums.FanfouSyncType.All, + Status = Common.Enums.FanfouUserAccountStatus.Active, + CreatedAt = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), + }; + await fanfouUserAccountRepository.InsertAsync(fanfouUserAccount); + + logger.LogInformation("Created new Fanfou user account for user {UserId}, username {Username}", + userId, username); + } + + logger.LogInformation("Successfully authenticated user {UserId} with Fanfou as {Username}", + userId, username); + + return Success("Successfully authenticated with Fanfou. You can close this page."); + } + catch (Exception ex) + { + logger.LogError(ex, "OAuth callback failed. Error: {ErrorMessage}", ex.Message); + return Fail($"Authentication failed: {ex.Message}"); + } + } + + private async Task<(string accessToken, string accessTokenSecret, string username)> ExchangeRequestTokenForAccessToken( + string consumerKey, + string consumerSecret, + string requestToken, + string requestTokenSecret, + string oauthVerifier) + { + logger.LogInformation("Exchanging request token for access token"); + + try + { + // For now, we'll need to use the FanfouStatusService or create a helper + // This is a simplified implementation - in production, you'd use proper OAuth 1.0a signing + // For the complete implementation, we need to add the token exchange logic to FanfouStatusService + + // TODO: Implement the OAuth access token exchange + // This requires signing the request with OAuth 1.0a + throw new NotImplementedException("Access token exchange needs to be implemented in FanfouStatusService"); + } + catch (Exception ex) + { + logger.LogError(ex, "Exception during access token exchange: {ErrorMessage}", ex.Message); + throw; + } + } +} diff --git a/src/HappyNotes.Api/Controllers/FanfouUserAccountController.cs b/src/HappyNotes.Api/Controllers/FanfouUserAccountController.cs new file mode 100644 index 0000000..e0ea175 --- /dev/null +++ b/src/HappyNotes.Api/Controllers/FanfouUserAccountController.cs @@ -0,0 +1,147 @@ +using Api.Framework; +using Api.Framework.Helper; +using Api.Framework.Result; +using HappyNotes.Common.Enums; +using HappyNotes.Dto; +using HappyNotes.Entities; +using HappyNotes.Services.interfaces; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; + +namespace HappyNotes.Api.Controllers; + +[Authorize] +[Route("api/[controller]")] +[ApiController] +public class FanfouAccountsController( + IRepositoryBase fanfouUserAccountRepository, + IFanfouUserAccountCacheService fanfouUserAccountCacheService, + ICurrentUser currentUser, + ILogger logger) : BaseController +{ + [HttpGet] + public async Task>> GetAccounts() + { + try + { + var accounts = await fanfouUserAccountRepository.GetListAsync(a => a.UserId == currentUser.Id); + + var dtos = accounts.Select(a => new FanfouUserAccountDto + { + Id = a.Id, + UserId = a.UserId, + Username = a.Username, + SyncType = a.SyncType, + Status = a.Status, + CreatedAt = a.CreatedAt + }).ToList(); + + logger.LogDebug("Retrieved {AccountCount} Fanfou accounts for user {UserId}", + dtos.Count, currentUser.Id); + + return new SuccessfulResult>(dtos); + } + catch (Exception ex) + { + logger.LogError(ex, "Error getting Fanfou accounts for user {UserId}", currentUser.Id); + return new FailedResult>(null, $"Failed to retrieve accounts: {ex.Message}"); + } + } + + [HttpPost] + public async Task AddAccount([FromBody] PostFanfouAccountRequest request) + { + try + { + if (string.IsNullOrEmpty(request.ConsumerKey) || string.IsNullOrEmpty(request.ConsumerSecret)) + { + return Fail("Consumer key and secret are required"); + } + + // Create new account (will be activated after OAuth flow completes) + var account = new FanfouUserAccount + { + UserId = currentUser.Id, + Username = "Pending", // Will be updated after OAuth + ConsumerKey = TextEncryptionHelper.Encrypt(request.ConsumerKey, "TEMP_KEY"), // Will be re-encrypted + ConsumerSecret = TextEncryptionHelper.Encrypt(request.ConsumerSecret, "TEMP_KEY"), // Will be re-encrypted + AccessToken = string.Empty, + AccessTokenSecret = string.Empty, + SyncType = request.SyncType, + Status = FanfouUserAccountStatus.Disabled, + CreatedAt = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), + }; + + await fanfouUserAccountRepository.InsertAsync(account); + + // Clear cache + fanfouUserAccountCacheService.ClearCache(currentUser.Id); + + logger.LogInformation("Created new Fanfou account for user {UserId}, pending OAuth", currentUser.Id); + + return Success("Account created. Please complete OAuth authorization."); + } + catch (Exception ex) + { + logger.LogError(ex, "Error adding Fanfou account for user {UserId}", currentUser.Id); + return Fail($"Failed to add account: {ex.Message}"); + } + } + + [HttpPut("{id}")] + public async Task UpdateAccount(long id, [FromBody] PutFanfouAccountRequest request) + { + try + { + var account = await fanfouUserAccountRepository.GetFirstOrDefaultAsync(a => a.Id == id && a.UserId == currentUser.Id); + if (account == null) + { + return Fail("Account not found"); + } + + account.SyncType = request.SyncType; + await fanfouUserAccountRepository.UpdateAsync(account); + + // Clear cache + fanfouUserAccountCacheService.ClearCache(currentUser.Id); + + logger.LogInformation("Updated Fanfou account {AccountId} sync type to {SyncType} for user {UserId}", + id, request.SyncType, currentUser.Id); + + return Success("Account updated successfully"); + } + catch (Exception ex) + { + logger.LogError(ex, "Error updating Fanfou account {AccountId} for user {UserId}", id, currentUser.Id); + return Fail($"Failed to update account: {ex.Message}"); + } + } + + [HttpDelete("{id}")] + public async Task DeleteAccount(long id) + { + try + { + var account = await fanfouUserAccountRepository.GetFirstOrDefaultAsync(a => a.Id == id && a.UserId == currentUser.Id); + if (account == null) + { + return Fail("Account not found"); + } + + await fanfouUserAccountRepository.DeleteAsync(account); + + // Clear cache + fanfouUserAccountCacheService.ClearCache(currentUser.Id); + + logger.LogInformation("Deleted Fanfou account {AccountId} for user {UserId}", id, currentUser.Id); + + return Success("Account deleted successfully"); + } + catch (Exception ex) + { + logger.LogError(ex, "Error deleting Fanfou account {AccountId} for user {UserId}", id, currentUser.Id); + return Fail($"Failed to delete account: {ex.Message}"); + } + } +} diff --git a/src/HappyNotes.Api/ServiceExtensions.cs b/src/HappyNotes.Api/ServiceExtensions.cs index 8bcac6d..9b0ae3f 100644 --- a/src/HappyNotes.Api/ServiceExtensions.cs +++ b/src/HappyNotes.Api/ServiceExtensions.cs @@ -18,13 +18,17 @@ public static void RegisterServices(this IServiceCollection services) services.AddScoped(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddSingleton(); services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); } diff --git a/src/HappyNotes.Common/Constants.cs b/src/HappyNotes.Common/Constants.cs index 3ead04f..84e1906 100644 --- a/src/HappyNotes.Common/Constants.cs +++ b/src/HappyNotes.Common/Constants.cs @@ -7,6 +7,7 @@ public static class Constants public const int MaxPageSize = 60; public const int TelegramMessageLength = 4096; public const int MastodonTootLength = 500; + public const int FanfouStatusLength = 140; public const int TelegramCaptionLength = 1024; public const string TelegramSameTokenFlag = "the same token as the last setting"; public const string MastodonAppName = "HappyNotes"; @@ -16,9 +17,10 @@ public static class Constants public const string TelegramService = "telegram"; public const string MastodonService = "mastodon"; public const string ManticoreSearchService = "manticoresearch"; + public const string FanfouService = "fanfou"; /// /// All available sync services /// - public static readonly string[] AllSyncServices = { TelegramService, MastodonService, ManticoreSearchService }; + public static readonly string[] AllSyncServices = { TelegramService, MastodonService, ManticoreSearchService, FanfouService }; } diff --git a/src/HappyNotes.Common/Enums/FanfouSyncType.cs b/src/HappyNotes.Common/Enums/FanfouSyncType.cs new file mode 100644 index 0000000..5f21cea --- /dev/null +++ b/src/HappyNotes.Common/Enums/FanfouSyncType.cs @@ -0,0 +1,33 @@ +namespace HappyNotes.Common.Enums; + +public enum FanfouSyncType +{ + /// + /// Sync all notes + /// + All = 1, + + /// + /// Public notes only + /// + PublicOnly = 2, + + /// + /// Sync notes that have a fanfou tag + /// + TagFanfouOnly = 3, +} + +public static class FanfouSyncTypeExtensions +{ + public static FanfouSyncType Next(this FanfouSyncType syncType) + { + return syncType switch + { + FanfouSyncType.All => FanfouSyncType.PublicOnly, + FanfouSyncType.PublicOnly => FanfouSyncType.TagFanfouOnly, + FanfouSyncType.TagFanfouOnly => FanfouSyncType.All, + _ => FanfouSyncType.All, + }; + } +} diff --git a/src/HappyNotes.Common/Enums/FanfouUserAccountStatus.cs b/src/HappyNotes.Common/Enums/FanfouUserAccountStatus.cs new file mode 100644 index 0000000..3c9ac04 --- /dev/null +++ b/src/HappyNotes.Common/Enums/FanfouUserAccountStatus.cs @@ -0,0 +1,19 @@ +namespace HappyNotes.Common.Enums; + +public enum FanfouUserAccountStatus +{ + /// + /// Active account + /// + Active = 1, + + /// + /// Disabled account + /// + Disabled = 2, + + /// + /// Error state + /// + Error = 3, +} diff --git a/src/HappyNotes.Dto/FanfouUserAccountDto.cs b/src/HappyNotes.Dto/FanfouUserAccountDto.cs new file mode 100644 index 0000000..2599682 --- /dev/null +++ b/src/HappyNotes.Dto/FanfouUserAccountDto.cs @@ -0,0 +1,45 @@ +using HappyNotes.Common.Enums; + +namespace HappyNotes.Dto; + +/// +/// Data transfer object for Fanfou user account. +/// +public class FanfouUserAccountDto +{ + public long Id { get; set; } + public long UserId { get; set; } + public string Username { get; set; } = string.Empty; + public FanfouSyncType SyncType { get; set; } + public FanfouUserAccountStatus Status { get; set; } + public long CreatedAt { get; set; } + public string StatusText => Status.ToString(); +} + +/// +/// Request model for adding/updating Fanfou account +/// +public class PostFanfouAccountRequest +{ + public string ConsumerKey { get; set; } = string.Empty; + public string ConsumerSecret { get; set; } = string.Empty; + public FanfouSyncType SyncType { get; set; } = FanfouSyncType.All; +} + +/// +/// Request model for updating Fanfou account sync type +/// +public class PutFanfouAccountRequest +{ + public FanfouSyncType SyncType { get; set; } +} + +/// +/// Model for synced Fanfou account display +/// +public class FanfouSyncedAccount +{ + public long UserAccountId { get; set; } + public string Username { get; set; } = string.Empty; + public string StatusId { get; set; } = string.Empty; +} diff --git a/src/HappyNotes.Entities/FanfouUserAccount.cs b/src/HappyNotes.Entities/FanfouUserAccount.cs new file mode 100644 index 0000000..b809ccb --- /dev/null +++ b/src/HappyNotes.Entities/FanfouUserAccount.cs @@ -0,0 +1,38 @@ +using Api.Framework.Helper; +using HappyNotes.Common.Enums; +using SqlSugar; + +namespace HappyNotes.Entities; + +public class FanfouUserAccount +{ + [SugarColumn(IsPrimaryKey = true, IsIdentity = true)] + public long Id { get; set; } + + public long UserId { get; set; } + public string Username { get; set; } = string.Empty; + + // OAuth credentials (encrypted) + public string ConsumerKey { get; set; } = string.Empty; + public string ConsumerSecret { get; set; } = string.Empty; + public string AccessToken { get; set; } = string.Empty; + public string AccessTokenSecret { get; set; } = string.Empty; + + public FanfouSyncType SyncType { get; set; } = FanfouSyncType.All; + public FanfouUserAccountStatus Status { get; set; } + + public long CreatedAt { get; set; } + + private string? _decryptedAccessToken; + private string? _decryptedTokenSecret; + + public string DecryptedAccessToken(string key) + { + return _decryptedAccessToken ??= TextEncryptionHelper.Decrypt(AccessToken, key); + } + + public string DecryptedAccessTokenSecret(string key) + { + return _decryptedTokenSecret ??= TextEncryptionHelper.Decrypt(AccessTokenSecret, key); + } +} diff --git a/src/HappyNotes.Entities/Note.cs b/src/HappyNotes.Entities/Note.cs index a12f254..7d5094b 100644 --- a/src/HappyNotes.Entities/Note.cs +++ b/src/HappyNotes.Entities/Note.cs @@ -16,6 +16,7 @@ public class Note : EntityBase public string Tags { get; set; } = string.Empty; public string? TelegramMessageIds { get; set; } public string? MastodonTootIds { get; set; } + public string? FanfouStatusIds { get; set; } [SugarColumn(IsIgnore = true)] public User User { get; set; } = default!; [SugarColumn(IsIgnore = true)] public List TagList { get; set; } = []; @@ -69,4 +70,45 @@ public void RemoveMastodonTootId(long userAccountId, string tootId) MastodonTootIds = tootIds.Any() ? string.Join(",", tootIds) : null; } + + public void AddFanfouStatusId(long userAccountId, string statusId) + { + var statusIdEntry = $"{userAccountId}:{statusId}"; + + if (string.IsNullOrWhiteSpace(FanfouStatusIds)) + { + FanfouStatusIds = statusIdEntry; + } + else + { + var statusIds = FanfouStatusIds.Split(',').ToList(); + var existingIndex = statusIds.FindIndex(id => id.StartsWith($"{userAccountId}:")); + + if (existingIndex >= 0) + { + statusIds[existingIndex] = statusIdEntry; + } + else + { + statusIds.Add(statusIdEntry); + } + + FanfouStatusIds = string.Join(",", statusIds); + } + } + + public void RemoveFanfouStatusId(long userAccountId, string statusId) + { + if (string.IsNullOrWhiteSpace(FanfouStatusIds)) + { + return; + } + + var statusIds = FanfouStatusIds.Split(',').ToList(); + var targetId = $"{userAccountId}:{statusId}"; + + statusIds.RemoveAll(id => id.Equals(targetId, StringComparison.OrdinalIgnoreCase)); + + FanfouStatusIds = statusIds.Any() ? string.Join(",", statusIds) : null; + } } diff --git a/src/HappyNotes.Services/FanfouStatusService.cs b/src/HappyNotes.Services/FanfouStatusService.cs new file mode 100644 index 0000000..23fa555 --- /dev/null +++ b/src/HappyNotes.Services/FanfouStatusService.cs @@ -0,0 +1,335 @@ +using System.Collections.Specialized; +using System.Diagnostics.CodeAnalysis; +using System.Net; +using System.Security.Cryptography; +using System.Text; +using System.Web; +using HappyNotes.Services.interfaces; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json.Linq; + +namespace HappyNotes.Services; + +internal static class OAuth1Helper +{ + private const string OAuthVersion = "1.0"; + private const string OAuthSignatureMethod = "HMAC-SHA1"; + private static readonly string[] OAuthReservedCharacters = { "!", "*", "'", "(", ")" }; + + public static string GenerateSignature( + string url, + string method, + List> parameters, + string consumerSecret, + string tokenSecret) + { + // Sort parameters + var sortedParams = parameters.OrderBy(p => p.Key).ThenBy(p => p.Value).ToList(); + + // Build parameter string + var parameterString = string.Join("&", sortedParams.Select(p => + $"{UrlEncode(p.Key)}={UrlEncode(p.Value)}")); + + // Build signature base string + var signatureBaseString = $"{method.ToUpper()}&{UrlEncode(url)}&{UrlEncode(parameterString)}"; + + // Build signing key + var signingKey = $"{UrlEncode(consumerSecret)}&{UrlEncode(tokenSecret)}"; + + // Generate signature + using var hmac = new HMACSHA1(Encoding.ASCII.GetBytes(signingKey)); + var hash = hmac.ComputeHash(Encoding.ASCII.GetBytes(signatureBaseString)); + return Convert.ToBase64String(hash); + } + + public static string UrlEncode(string value) + { + if (string.IsNullOrEmpty(value)) return string.Empty; + + var encoded = new StringBuilder(); + foreach (char c in value) + { + if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')) + { + encoded.Append(c); + } + else if (OAuthReservedCharacters.Contains(c.ToString())) + { + encoded.Append(c); + } + else + { + encoded.Append('%' + $"{(int)c:X2}"); + } + } + return encoded.ToString(); + } + + public static Dictionary GenerateOAuthParameters( + string consumerKey, + string token, + string callbackUrl = "oob") + { + var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(); + var nonce = Convert.ToBase64String(RandomNumberGenerator.GetBytes(16)).Replace("=", "").Replace("+", "").Replace("/", ""); + + return new Dictionary + { + { "oauth_callback", callbackUrl }, + { "oauth_consumer_key", consumerKey }, + { "oauth_nonce", nonce }, + { "oauth_signature_method", OAuthSignatureMethod }, + { "oauth_timestamp", timestamp }, + { "oauth_token", token }, + { "oauth_version", OAuthVersion } + }; + } + + public static Dictionary GenerateOAuthParametersForRequestToken( + string consumerKey, + string callbackUrl = "oob") + { + var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(); + var nonce = Convert.ToBase64String(RandomNumberGenerator.GetBytes(16)).Replace("=", "").Replace("+", "").Replace("/", ""); + + return new Dictionary + { + { "oauth_callback", callbackUrl }, + { "oauth_consumer_key", consumerKey }, + { "oauth_nonce", nonce }, + { "oauth_signature_method", OAuthSignatureMethod }, + { "oauth_timestamp", timestamp }, + { "oauth_version", OAuthVersion } + }; + } +} + +public class FanfouStatusService( + IHttpClientFactory httpClientFactory, + ILogger logger) : IFanfouStatusService +{ + private const string FanfouApiBaseUrl = "https://api.fanfou.com"; + + public async Task SendStatusAsync( + string consumerKey, + string consumerSecret, + string accessToken, + string accessTokenSecret, + string content, + CancellationToken cancellationToken = default) + { + logger.LogInformation("Sending status to Fanfou. ContentLength: {ContentLength}", content.Length); + + try + { + var url = $"{FanfouApiBaseUrl}/statuses/update.json"; + var parameters = new List> + { + new("status", content) + }; + + var response = await SendOAuthRequestAsync( + url, + "POST", + consumerKey, + consumerSecret, + accessToken, + accessTokenSecret, + parameters, + cancellationToken); + + var json = JObject.Parse(response); + var statusId = json["id"]?.ToString() ?? throw new Exception("Failed to get status ID from response"); + + logger.LogInformation("Successfully sent status to Fanfou. StatusId: {StatusId}", statusId); + return statusId; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to send status to Fanfou"); + throw; + } + } + + public async Task SendStatusWithPhotoAsync( + string consumerKey, + string consumerSecret, + string accessToken, + string accessTokenSecret, + byte[] photoData, + string? statusText = null, + CancellationToken cancellationToken = default) + { + logger.LogInformation("Sending photo status to Fanfou. PhotoSize: {PhotoSize} bytes", photoData.Length); + + try + { + var url = $"{FanfouApiBaseUrl}/photos/upload.json"; + + // For multipart/form-data, we need a different approach + using var client = httpClientFactory.CreateClient(); + using var content = new MultipartFormDataContent(); + + // Add photo + content.Add(new ByteArrayContent(photoData), "photo", "photo.jpg"); + + // Add status text if provided + if (!string.IsNullOrEmpty(statusText)) + { + content.Add(new StringContent(statusText), "status"); + } + + // Generate OAuth Authorization header for multipart request + var authHeader = GenerateAuthorizationHeader( + url, + "POST", + consumerKey, + consumerSecret, + accessToken, + accessTokenSecret, + []); + + client.DefaultRequestHeaders.Clear(); + client.DefaultRequestHeaders.Add("Authorization", authHeader); + + var response = await client.PostAsync(url, content, cancellationToken); + response.EnsureSuccessStatusCode(); + + var responseContent = await response.Content.ReadAsStringAsync(cancellationToken); + var json = JObject.Parse(responseContent); + var statusId = json["id"]?.ToString() ?? throw new Exception("Failed to get status ID from response"); + + logger.LogInformation("Successfully sent photo status to Fanfou. StatusId: {StatusId}", statusId); + return statusId; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to send photo status to Fanfou"); + throw; + } + } + + public async Task DeleteStatusAsync( + string consumerKey, + string consumerSecret, + string accessToken, + string accessTokenSecret, + string statusId, + CancellationToken cancellationToken = default) + { + logger.LogInformation("Deleting status from Fanfou. StatusId: {StatusId}", statusId); + + try + { + var url = $"{FanfouApiBaseUrl}/statuses/destroy/{statusId}.json"; + + await SendOAuthRequestAsync( + url, + "POST", + consumerKey, + consumerSecret, + accessToken, + accessTokenSecret, + [], + cancellationToken); + + logger.LogInformation("Successfully deleted status {StatusId} from Fanfou", statusId); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to delete status {StatusId} from Fanfou", statusId); + throw; + } + } + + private async Task SendOAuthRequestAsync( + string url, + string method, + string consumerKey, + string consumerSecret, + string token, + string tokenSecret, + List> parameters, + CancellationToken cancellationToken) + { + using var client = httpClientFactory.CreateClient(); + + // Generate OAuth parameters + var oauthParams = OAuth1Helper.GenerateOAuthParameters(consumerKey, token); + + // Build signature base string with all parameters + var allParams = new List>(parameters); + foreach (var param in oauthParams) + { + allParams.Add(new KeyValuePair(param.Key, param.Value)); + } + + // Generate signature + var signature = OAuth1Helper.GenerateSignature( + url, + method, + allParams, + consumerSecret, + tokenSecret); + + oauthParams["oauth_signature"] = signature; + + // Build Authorization header + var authHeader = "OAuth " + string.Join(", ", oauthParams + .OrderBy(p => p.Key) + .Select(p => $"{OAuth1Helper.UrlEncode(p.Key)}=\"{OAuth1Helper.UrlEncode(p.Value)}\"")); + + // Send request + client.DefaultRequestHeaders.Clear(); + client.DefaultRequestHeaders.Add("Authorization", authHeader); + + HttpResponseMessage response; + + if (method.ToUpper() == "GET") + { + var queryString = string.Join("&", parameters.Select(p => + $"{HttpUtility.UrlEncode(p.Key)}={HttpUtility.UrlEncode(p.Value)}")); + var fullUrl = string.IsNullOrEmpty(queryString) ? url : $"{url}?{queryString}"; + response = await client.GetAsync(fullUrl, cancellationToken); + } + else + { + response = await client.PostAsync(url, new FormUrlEncodedContent(parameters), cancellationToken); + } + + // Check for rate limiting + if (response.StatusCode == HttpStatusCode.Forbidden || response.StatusCode == (HttpStatusCode)429) + { + logger.LogWarning("Fanfou rate limit exceeded"); + throw new Exception("Fanfou rate limit exceeded. Please try again later."); + } + + response.EnsureSuccessStatusCode(); + return await response.Content.ReadAsStringAsync(cancellationToken); + } + + private static string GenerateAuthorizationHeader( + string url, + string method, + string consumerKey, + string consumerSecret, + string token, + string tokenSecret, + List> parameters) + { + var oauthParams = OAuth1Helper.GenerateOAuthParameters(consumerKey, token); + + var allParams = new List>(parameters); + foreach (var param in oauthParams) + { + allParams.Add(new KeyValuePair(param.Key, param.Value)); + } + + var signature = OAuth1Helper.GenerateSignature(url, method, allParams, consumerSecret, tokenSecret); + oauthParams["oauth_signature"] = signature; + + return "OAuth " + string.Join(", ", oauthParams + .OrderBy(p => p.Key) + .Select(p => $"{OAuth1Helper.UrlEncode(p.Key)}=\"{OAuth1Helper.UrlEncode(p.Value)}\"")); + } +} diff --git a/src/HappyNotes.Services/FanfouSyncNoteService.cs b/src/HappyNotes.Services/FanfouSyncNoteService.cs new file mode 100644 index 0000000..7198a94 --- /dev/null +++ b/src/HappyNotes.Services/FanfouSyncNoteService.cs @@ -0,0 +1,299 @@ +using Api.Framework.Models; +using HappyNotes.Common.Enums; +using HappyNotes.Entities; +using HappyNotes.Repositories.interfaces; +using HappyNotes.Services.interfaces; +using HappyNotes.Services.SyncQueue.Interfaces; +using HappyNotes.Services.SyncQueue.Models; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace HappyNotes.Services; + +public class FanfouSyncNoteService( + INoteRepository noteRepository, + IFanfouUserAccountCacheService fanfouUserAccountCacheService, + IOptions jwtConfig, + ISyncQueueService syncQueueService, + ILogger logger +) + : ISyncNoteService +{ + private readonly JwtConfig _jwtConfig = jwtConfig.Value; + + public async Task SyncNewNote(Note note, string fullContent) + { + logger.LogInformation("Starting sync of new note {NoteId} for user {UserId}. ContentLength: {ContentLength}, IsPrivate: {IsPrivate}, IsMarkdown: {IsMarkdown}", + note.Id, note.UserId, fullContent.Length, note.IsPrivate, note.IsMarkdown); + + try + { + var accounts = await _GetToSyncFanfouUserAccounts(note); + logger.LogDebug("Found {AccountCount} Fanfou accounts to sync for note {NoteId}", + accounts.Count, note.Id); + + if (accounts.Any()) + { + // UNIFIED QUEUE MODE: Always use queue for consistent behavior + foreach (var account in accounts) + { + await EnqueueSyncTask(note, fullContent, account, "CREATE"); + logger.LogDebug("Queued CREATE task for note {NoteId} to Fanfou account {AccountId}", + note.Id, account.Id); + } + } + } + catch (Exception ex) + { + logger.LogError(ex, "Error in SyncNewNote for note {NoteId}", note.Id); + } + } + + public async Task SyncEditNote(Note note, string fullContent, Note existingNote) + { + logger.LogInformation("Starting sync edit of note {NoteId} for user {UserId}. ContentLength: {ContentLength}, IsPrivate: {IsPrivate}, IsMarkdown: {IsMarkdown}", + note.Id, note.UserId, fullContent.Length, note.IsPrivate, note.IsMarkdown); + + try + { + var hasContentChange = _HasContentChanged(existingNote, note, fullContent); + var accounts = await fanfouUserAccountCacheService.GetAsync(note.UserId); + if (!accounts.Any()) + { + logger.LogDebug("No Fanfou accounts found for user {UserId}, skipping sync edit of note {NoteId}", + note.UserId, note.Id); + return; + } + + var accountData = await _GetAccountsData(note); + var toBeSent = accountData.toBeSent; + var tobeRemoved = accountData.toBeRemoved; + var tobeUpdated = accountData.toBeUpdated; + + logger.LogDebug("Edit sync plan for note {NoteId}: {ToSendCount} to send, {ToUpdateCount} to update, {ToRemoveCount} to remove", + note.Id, toBeSent.Count, tobeUpdated.Count, tobeRemoved.Count); + + foreach (var account in tobeRemoved) + { + var syncedStatuses = _GetSyncedStatuses(note); + var status = syncedStatuses.FirstOrDefault(s => s.UserAccountId == account.Id); + if (status != null) + { + await EnqueueSyncTask(note, string.Empty, account, "DELETE", status.StatusId); + logger.LogDebug("Queued DELETE task for note {NoteId} from Fanfou account {AccountId}", + note.Id, account.Id); + } + } + + foreach (var account in tobeUpdated) + { + // Fanfou has no edit API, so we use DELETE + CREATE for content changes + if (hasContentChange) + { + var syncedStatuses = _GetSyncedStatuses(note); + var status = syncedStatuses.FirstOrDefault(s => s.UserAccountId == account.Id); + if (status != null) + { + // Queue DELETE for existing status + await EnqueueSyncTask(note, string.Empty, account, "DELETE", status.StatusId); + logger.LogDebug("Queued DELETE task for note {NoteId} from Fanfou account {AccountId} (content changed)", + note.Id, account.Id); + + // Queue CREATE for new content + await EnqueueSyncTask(note, fullContent, account, "CREATE"); + logger.LogDebug("Queued CREATE task for note {NoteId} to Fanfou account {AccountId} (content changed)", + note.Id, account.Id); + } + } + } + + foreach (var account in toBeSent) + { + await EnqueueSyncTask(note, fullContent, account, "CREATE"); + logger.LogDebug("Queued CREATE task for note {NoteId} to Fanfou account {AccountId}", + note.Id, account.Id); + } + } + catch (Exception ex) + { + logger.LogError(ex, "Error in SyncEditNote for note {NoteId}", note.Id); + } + } + + public async Task SyncDeleteNote(Note note) + { + logger.LogInformation("Starting sync delete of note {NoteId} for user {UserId}", note.Id, note.UserId); + + if (!string.IsNullOrWhiteSpace(note.FanfouStatusIds)) + { + try + { + var accounts = await fanfouUserAccountCacheService.GetAsync(note.UserId); + if (!accounts.Any()) + { + logger.LogDebug("No Fanfou accounts found for user {UserId}, skipping sync delete of note {NoteId}", + note.UserId, note.Id); + return; + } + + var syncedStatuses = _GetSyncedStatuses(note); + logger.LogDebug("Deleting note {NoteId} from {StatusCount} Fanfou statuses", + note.Id, syncedStatuses.Count); + + foreach (var status in syncedStatuses) + { + var account = accounts.FirstOrDefault(s => s.Id.Equals(status.UserAccountId)); + if (account != null) + { + await EnqueueSyncTask(note, string.Empty, account, "DELETE", status.StatusId); + logger.LogDebug("Queued DELETE task for note {NoteId} from Fanfou account {AccountId}", + note.Id, account.Id); + } + else + { + logger.LogWarning("Account {AccountId} not found for deleting status {StatusId} from note {NoteId}", + status.UserAccountId, status.StatusId, note.Id); + } + } + } + catch (Exception ex) + { + logger.LogError(ex, "Error enqueueing Fanfou delete tasks for note {NoteId}", note.Id); + } + } + else + { + logger.LogDebug("Note {NoteId} has no Fanfou sync data, nothing to delete", note.Id); + } + } + + public async Task SyncUndeleteNote(Note note) + { + await Task.CompletedTask; + } + + public async Task PurgeDeletedNotes() + { + await Task.CompletedTask; + } + + private async Task<(List toBeUpdated, List toBeRemoved, + List toBeSent)> _GetAccountsData(Note note) + { + var syncedStatuses = _GetSyncedStatuses(note); + var toSyncAccounts = await _GetToSyncFanfouUserAccounts(note); + + var toBeUpdated = _GetAccountsToBeUpdated(syncedStatuses, toSyncAccounts); + var toBeRemoved = _GetAccountsToBeRemoved(syncedStatuses, toSyncAccounts); + var toBeSent = _GetAccountsToBeSent(syncedStatuses, toSyncAccounts); + + return (toBeUpdated, toBeRemoved, toBeSent); + } + + private async Task> _GetToSyncFanfouUserAccounts(Note note) + { + var result = new List(); + var all = await fanfouUserAccountCacheService.GetAsync(note.UserId); + foreach (var account in all) + { + switch (account.SyncType) + { + case FanfouSyncType.All: + result.Add(account); + break; + case FanfouSyncType.PublicOnly: + if (!note.IsPrivate) + { + result.Add(account); + } + break; + case FanfouSyncType.TagFanfouOnly: + if (note.TagList.Contains("fanfou")) + { + result.Add(account); + } + break; + } + } + + return result; + } + + private List _GetAccountsToBeUpdated(List statuses, + IList toSyncAccounts) + { + var idsToUpdate = statuses.Select(i => i.UserAccountId).Intersect(toSyncAccounts.Select(t => t.Id)).ToList(); + return toSyncAccounts.Where(r => idsToUpdate.Contains(r.Id)).ToList(); + } + + private List _GetAccountsToBeRemoved(List statuses, + IList toSyncAccounts) + { + if (!toSyncAccounts.Any()) return []; + + var idsToRemove = statuses.Select(s => s.UserAccountId).Except(toSyncAccounts.Select(t => t.Id)).ToList(); + return toSyncAccounts.Where(r => idsToRemove.Contains(r.Id)).ToList(); + } + + private List _GetAccountsToBeSent(List syncedStatuses, + IList toSyncAccounts) + { + if (!syncedStatuses.Any()) return toSyncAccounts.ToList(); + var toSendUserAccountId = toSyncAccounts.Select(t => t.Id).Except(syncedStatuses.Select(s => s.UserAccountId)) + .ToList(); + return toSyncAccounts.Where(t => toSendUserAccountId.Contains(t.Id)).ToList(); + } + + private async Task EnqueueSyncTask(Note note, string fullContent, FanfouUserAccount account, string action, string? statusId = null) + { + var payload = new FanfouSyncPayload + { + UserAccountId = account.Id, + FullContent = fullContent, + StatusId = statusId, + IsPrivate = note.IsPrivate, + IsMarkdown = note.IsMarkdown + }; + + var task = SyncTask.Create("fanfou", action, note.Id, note.UserId, payload); + await syncQueueService.EnqueueAsync("fanfou", task); + + logger.LogInformation("Enqueued Fanfou sync task {TaskId} for note {NoteId}, action: {Action}, account: {AccountId}", + task.Id, note.Id, action, account.Id); + } + + private static List _GetSyncedStatuses(Note note) + { + if (string.IsNullOrWhiteSpace(note.FanfouStatusIds)) return new List(); + return note.FanfouStatusIds.Split(",").Select(s => + { + var parts = s.Split(":"); + return new FanfouSyncedStatus + { + UserAccountId = long.Parse(parts[0]), + StatusId = parts[1], + }; + }).ToList(); + } + + private bool _HasContentChanged(Note existingNote, Note newNote, string newFullContent) + { + if (existingNote.Content != newFullContent) + { + return true; + } + + if (existingNote.IsMarkdown != newNote.IsMarkdown) + { + return true; + } + + return false; + } +} + +public class FanfouSyncedStatus +{ + public long UserAccountId { get; set; } + public string StatusId { get; set; } = string.Empty; +} diff --git a/src/HappyNotes.Services/FanfouUserAccountCacheService.cs b/src/HappyNotes.Services/FanfouUserAccountCacheService.cs new file mode 100644 index 0000000..bda7e7d --- /dev/null +++ b/src/HappyNotes.Services/FanfouUserAccountCacheService.cs @@ -0,0 +1,44 @@ +using Api.Framework; +using HappyNotes.Common.Enums; +using HappyNotes.Entities; +using HappyNotes.Services.interfaces; +using Microsoft.Extensions.Caching.Memory; + +namespace HappyNotes.Services; + +public class FanfouUserAccountCacheService( + IMemoryCache cache, + IRepositoryBase fanfouUserAccountRepository) + : IFanfouUserAccountCacheService +{ + private static string CacheKey(long userId) => $"FUA_{userId}"; + + // Set cache options + private static readonly MemoryCacheEntryOptions CacheEntryOptions = new MemoryCacheEntryOptions() + .SetSlidingExpiration(TimeSpan.FromMinutes(1440)); // Set expiration time + + public async Task> GetAsync(long userId) + { + if (cache.TryGetValue(CacheKey(userId), out List? config)) + { + return config!; + } + + // If not in cache, load from the database + var settings = await fanfouUserAccountRepository.GetListAsync( + s => s.UserId == userId && s.Status == FanfouUserAccountStatus.Active); + + Set(userId, settings); + return settings; + } + + public void Set(long userId, IList account) + { + cache.Set(CacheKey(userId), account, CacheEntryOptions); + } + + public void ClearCache(long userId) + { + cache.Remove(CacheKey(userId)); + } +} diff --git a/src/HappyNotes.Services/MastodonTootService.cs b/src/HappyNotes.Services/MastodonTootService.cs index 95ad117..4db5b88 100644 --- a/src/HappyNotes.Services/MastodonTootService.cs +++ b/src/HappyNotes.Services/MastodonTootService.cs @@ -1,4 +1,3 @@ -using CoreHtmlToImage; using HappyNotes.Common; using HappyNotes.Services.interfaces; using Markdig; @@ -8,8 +7,10 @@ namespace HappyNotes.Services; -public class MastodonTootService(ILogger logger -, IHttpClientFactory httpClientFactory) : IMastodonTootService +public class MastodonTootService( + ILogger logger, + IHttpClientFactory httpClientFactory, + ITextToImageService textToImageService) : IMastodonTootService { private const int MaxImages = 4; @@ -188,14 +189,10 @@ private static string _GetFullText(string text, bool isMarkdown) return text; } - private static async Task _UploadLongTextAsMedia(MastodonClient client, string longText, + private async Task _UploadLongTextAsMedia(MastodonClient client, string longText, bool isMarkdown) { - var htmlContent = isMarkdown ? longText : longText.Replace("\n", "
"); - htmlContent = - $"\n\n\n\n\n\n{htmlContent}"; - var converter = new HtmlConverter(); - var bytes = converter.FromHtmlString(htmlContent, width: 600); + var bytes = await textToImageService.GenerateImageAsync(longText, isMarkdown, width: 600); var memoryStream = new MemoryStream(bytes); var media = await client.UploadMedia(memoryStream, "long_text.jpg"); return media; diff --git a/src/HappyNotes.Services/SyncQueue/Extensions/ServiceCollectionExtensions.cs b/src/HappyNotes.Services/SyncQueue/Extensions/ServiceCollectionExtensions.cs index d2c8299..31fbe1e 100644 --- a/src/HappyNotes.Services/SyncQueue/Extensions/ServiceCollectionExtensions.cs +++ b/src/HappyNotes.Services/SyncQueue/Extensions/ServiceCollectionExtensions.cs @@ -63,6 +63,7 @@ public static IServiceCollection AddSyncQueue(this IServiceCollection services, // Register handlers services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); // Register background processor diff --git a/src/HappyNotes.Services/SyncQueue/Handlers/FanfouSyncHandler.cs b/src/HappyNotes.Services/SyncQueue/Handlers/FanfouSyncHandler.cs new file mode 100644 index 0000000..818e839 --- /dev/null +++ b/src/HappyNotes.Services/SyncQueue/Handlers/FanfouSyncHandler.cs @@ -0,0 +1,273 @@ +using System.Text.Json; +using Api.Framework.Helper; +using Api.Framework.Models; +using HappyNotes.Common; +using HappyNotes.Entities; +using HappyNotes.Repositories.interfaces; +using HappyNotes.Services.interfaces; +using HappyNotes.Services.SyncQueue.Configuration; +using HappyNotes.Services.SyncQueue.Interfaces; +using HappyNotes.Services.SyncQueue.Models; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace HappyNotes.Services.SyncQueue.Handlers; + +public class FanfouSyncHandler : ISyncHandler +{ + private readonly IFanfouStatusService _fanfouStatusService; + private readonly IFanfouUserAccountCacheService _fanfouUserAccountCacheService; + private readonly ITextToImageService _textToImageService; + private readonly INoteRepository _noteRepository; + private readonly SyncQueueOptions _options; + private readonly JwtConfig _jwtConfig; + private readonly ILogger _logger; + + public string ServiceName => Constants.FanfouService; + + public int MaxRetryAttempts => _options.Handlers.TryGetValue(ServiceName, out var config) + ? config.MaxRetries + : 5; + + public FanfouSyncHandler( + IFanfouStatusService fanfouStatusService, + IFanfouUserAccountCacheService fanfouUserAccountCacheService, + ITextToImageService textToImageService, + INoteRepository noteRepository, + IOptions options, + IOptions jwtConfig, + ILogger logger) + { + _fanfouStatusService = fanfouStatusService; + _fanfouUserAccountCacheService = fanfouUserAccountCacheService; + _textToImageService = textToImageService; + _noteRepository = noteRepository; + _options = options.Value; + _jwtConfig = jwtConfig.Value; + _logger = logger; + } + + public async Task ProcessAsync(SyncTask task, CancellationToken cancellationToken) + { + try + { + FanfouSyncPayload? payload; + + // Handle both typed and untyped task objects + if (task.Payload is JsonElement jsonElement) + { + payload = JsonSerializer.Deserialize(jsonElement.GetRawText(), JsonSerializerConfig.Default); + } + else if (task.Payload is FanfouSyncPayload fanfouPayload) + { + payload = fanfouPayload; + } + else + { + return SyncResult.Failure("Invalid payload type", shouldRetry: false); + } + + if (payload == null) + { + return SyncResult.Failure("Failed to deserialize payload", shouldRetry: false); + } + + // Get FanfouUserAccount from cache + var userAccounts = await _fanfouUserAccountCacheService.GetAsync(task.UserId); + var userAccount = userAccounts.FirstOrDefault(a => a.Id == payload.UserAccountId); + + if (userAccount == null) + { + return SyncResult.Failure($"No Fanfou user account found for user {task.UserId} and account {payload.UserAccountId}", shouldRetry: false); + } + + var consumerKey = TextEncryptionHelper.Decrypt(userAccount.ConsumerKey, _jwtConfig.SymmetricSecurityKey); + var consumerSecret = TextEncryptionHelper.Decrypt(userAccount.ConsumerSecret, _jwtConfig.SymmetricSecurityKey); + var accessToken = userAccount.DecryptedAccessToken(_jwtConfig.SymmetricSecurityKey); + var accessTokenSecret = userAccount.DecryptedAccessTokenSecret(_jwtConfig.SymmetricSecurityKey); + + return task.Action.ToUpper() switch + { + "CREATE" => await ProcessCreateAction(task, payload, consumerKey, consumerSecret, accessToken, accessTokenSecret, cancellationToken), + "DELETE" => await ProcessDeleteAction(task, payload, consumerKey, consumerSecret, accessToken, accessTokenSecret, cancellationToken), + _ => SyncResult.Failure($"Unknown action: {task.Action}", shouldRetry: false) + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error processing Fanfou sync task {TaskId} for user {UserId}: {Error}", + task.Id, task.UserId, ex.Message); + return SyncResult.Failure(ex.Message); + } + } + + private async Task ProcessCreateAction( + SyncTask task, + FanfouSyncPayload payload, + string consumerKey, + string consumerSecret, + string accessToken, + string accessTokenSecret, + CancellationToken cancellationToken) + { + try + { + _logger.LogDebug("Processing CREATE action for task {TaskId}", task.Id); + + // Strip markdown for content length check (but preserve for image generation) + var plainContent = payload.FullContent; + + // Check if content exceeds 140 characters + if (plainContent.Length > Constants.FanfouStatusLength) + { + _logger.LogDebug("Content exceeds {CharLimit} characters, generating image for task {TaskId}", + Constants.FanfouStatusLength, task.Id); + + // Generate image using shared service (same as Mastodon) + var imageData = await _textToImageService.GenerateImageAsync( + payload.FullContent, + payload.IsMarkdown, + width: 600, + cancellationToken); + + // Upload via Fanfou's /photos/upload + var statusId = await _fanfouStatusService.SendStatusWithPhotoAsync( + consumerKey, consumerSecret, accessToken, accessTokenSecret, + imageData, + statusText: null, + cancellationToken); + + // Add the status ID to the note + await AddStatusIdToNote(task.EntityId, payload.UserAccountId, statusId); + + _logger.LogDebug("Successfully created photo status {StatusId} for task {TaskId}", + statusId, task.Id); + + return SyncResult.Success(); + } + else + { + // Send as normal text status + var statusId = await _fanfouStatusService.SendStatusAsync( + consumerKey, consumerSecret, accessToken, accessTokenSecret, + plainContent, + cancellationToken); + + // Add the status ID to the note + await AddStatusIdToNote(task.EntityId, payload.UserAccountId, statusId); + + _logger.LogDebug("Successfully created text status {StatusId} for task {TaskId}", + statusId, task.Id); + + return SyncResult.Success(); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to create status for task {TaskId}: {Error}", + task.Id, ex.Message); + return SyncResult.Failure(ex.Message); + } + } + + private async Task ProcessDeleteAction( + SyncTask task, + FanfouSyncPayload payload, + string consumerKey, + string consumerSecret, + string accessToken, + string accessTokenSecret, + CancellationToken cancellationToken) + { + try + { + if (string.IsNullOrEmpty(payload.StatusId)) + { + return SyncResult.Failure("StatusId is required for DELETE action", shouldRetry: false); + } + + _logger.LogDebug("Processing DELETE action for task {TaskId}, status {StatusId}", + task.Id, payload.StatusId); + + await _fanfouStatusService.DeleteStatusAsync( + consumerKey, consumerSecret, accessToken, accessTokenSecret, + payload.StatusId, + cancellationToken); + + // Remove the status ID from the note + await RemoveStatusIdFromNote(task.EntityId, payload.UserAccountId, payload.StatusId); + + _logger.LogDebug("Successfully deleted status {StatusId} for task {TaskId}", + payload.StatusId, task.Id); + + return SyncResult.Success(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to delete status {StatusId} for task {TaskId}: {Error}", + payload.StatusId, task.Id, ex.Message); + return SyncResult.Failure(ex.Message); + } + } + + public TimeSpan CalculateRetryDelay(int attemptCount) + { + if (_options.Handlers.TryGetValue(ServiceName, out var config)) + { + var delay = TimeSpan.FromSeconds(config.BaseDelaySeconds * Math.Pow(config.BackoffMultiplier, attemptCount - 1)); + var maxDelay = TimeSpan.FromMinutes(config.MaxDelayMinutes); + return delay > maxDelay ? maxDelay : delay; + } + + // Default fallback + return TimeSpan.FromMinutes(Math.Min(Math.Pow(2, attemptCount - 1), 30)); + } + + private async Task AddStatusIdToNote(long noteId, long userAccountId, string statusId) + { + // First get current FanfouStatusIds to compute the new value + var currentNote = await _noteRepository.GetFirstOrDefaultAsync( + n => n.Id == noteId, + orderBy: null); + + if (currentNote == null) + { + _logger.LogWarning("Note {NoteId} not found, cannot add Fanfou status ID", noteId); + return; + } + + currentNote.AddFanfouStatusId(userAccountId, statusId); + + // Use precise update - only update FanfouStatusIds field + await _noteRepository.UpdateAsync( + note => new Note { FanfouStatusIds = currentNote.FanfouStatusIds }, + note => note.Id == noteId); + + _logger.LogDebug("Updated note {NoteId} FanfouStatusIds after adding {UserAccountId}:{StatusId}", + noteId, userAccountId, statusId); + } + + private async Task RemoveStatusIdFromNote(long noteId, long userAccountId, string statusId) + { + // Get current FanfouStatusIds to compute the new value + var currentNote = await _noteRepository.GetFirstOrDefaultAsync( + n => n.Id == noteId, + orderBy: null); + + if (currentNote == null || string.IsNullOrWhiteSpace(currentNote.FanfouStatusIds)) + { + _logger.LogWarning("Note {NoteId} not found or has no Fanfou status IDs", noteId); + return; + } + + currentNote.RemoveFanfouStatusId(userAccountId, statusId); + + // Use precise update - only update FanfouStatusIds field + await _noteRepository.UpdateAsync( + note => new Note { FanfouStatusIds = currentNote.FanfouStatusIds }, + note => note.Id == noteId); + + _logger.LogDebug("Updated note {NoteId} FanfouStatusIds after removing {UserAccountId}:{StatusId}", + noteId, userAccountId, statusId); + } +} diff --git a/src/HappyNotes.Services/SyncQueue/Models/FanfouSyncPayload.cs b/src/HappyNotes.Services/SyncQueue/Models/FanfouSyncPayload.cs new file mode 100644 index 0000000..be86a4e --- /dev/null +++ b/src/HappyNotes.Services/SyncQueue/Models/FanfouSyncPayload.cs @@ -0,0 +1,11 @@ +namespace HappyNotes.Services.SyncQueue.Models; + +public class FanfouSyncPayload +{ + public long UserAccountId { get; set; } + public string FullContent { get; set; } = string.Empty; + public string? StatusId { get; set; } // For DELETE operations + public bool IsPrivate { get; set; } + public bool IsMarkdown { get; set; } + public Dictionary Metadata { get; set; } = new(); +} diff --git a/src/HappyNotes.Services/TextToImageService.cs b/src/HappyNotes.Services/TextToImageService.cs new file mode 100644 index 0000000..d0ac530 --- /dev/null +++ b/src/HappyNotes.Services/TextToImageService.cs @@ -0,0 +1,47 @@ +using CoreHtmlToImage; +using HappyNotes.Services.interfaces; +using Markdig; +using Microsoft.Extensions.Logging; + +namespace HappyNotes.Services; + +public class TextToImageService(ILogger logger) : ITextToImageService +{ + public async Task GenerateImageAsync( + string content, + bool isMarkdown = false, + int width = 600, + CancellationToken cancellationToken = default) + { + logger.LogDebug("Generating image for content. Length: {ContentLength}, IsMarkdown: {IsMarkdown}, Width: {Width}", + content.Length, isMarkdown, width); + + try + { + // Convert markdown to HTML if needed + var htmlContent = isMarkdown + ? Markdown.ToHtml(content, new MarkdownPipelineBuilder() + .UseAdvancedExtensions() + .Build()) + : content.Replace("\n", "
"); + + // Wrap in HTML template with existing CSS + htmlContent = + $"\n\n\n\n\n\n{htmlContent}"; + + // Convert to image using existing library + var converter = new HtmlConverter(); + var bytes = converter.FromHtmlString(htmlContent, width: width); + + logger.LogDebug("Successfully generated image. Size: {ByteCount} bytes", bytes.Length); + + return await Task.FromResult(bytes); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to generate image from content. Length: {ContentLength}, IsMarkdown: {IsMarkdown}", + content.Length, isMarkdown); + throw; + } + } +} diff --git a/src/HappyNotes.Services/interfaces/IFanfouStatusService.cs b/src/HappyNotes.Services/interfaces/IFanfouStatusService.cs new file mode 100644 index 0000000..3c5ae21 --- /dev/null +++ b/src/HappyNotes.Services/interfaces/IFanfouStatusService.cs @@ -0,0 +1,38 @@ +namespace HappyNotes.Services.interfaces; + +public interface IFanfouStatusService +{ + /// + /// Send a short status (text only) to Fanfou + /// + Task SendStatusAsync( + string consumerKey, + string consumerSecret, + string accessToken, + string accessTokenSecret, + string content, + CancellationToken cancellationToken = default); + + /// + /// Send a long status as photo with optional status text to Fanfou + /// + Task SendStatusWithPhotoAsync( + string consumerKey, + string consumerSecret, + string accessToken, + string accessTokenSecret, + byte[] photoData, + string? statusText = null, + CancellationToken cancellationToken = default); + + /// + /// Delete a status from Fanfou + /// + Task DeleteStatusAsync( + string consumerKey, + string consumerSecret, + string accessToken, + string accessTokenSecret, + string statusId, + CancellationToken cancellationToken = default); +} diff --git a/src/HappyNotes.Services/interfaces/IFanfouUserAccountCacheService.cs b/src/HappyNotes.Services/interfaces/IFanfouUserAccountCacheService.cs new file mode 100644 index 0000000..475efb5 --- /dev/null +++ b/src/HappyNotes.Services/interfaces/IFanfouUserAccountCacheService.cs @@ -0,0 +1,10 @@ +using HappyNotes.Entities; + +namespace HappyNotes.Services.interfaces; + +public interface IFanfouUserAccountCacheService +{ + Task> GetAsync(long userId); + void Set(long userId, IList account); + void ClearCache(long userId); +} diff --git a/src/HappyNotes.Services/interfaces/ITextToImageService.cs b/src/HappyNotes.Services/interfaces/ITextToImageService.cs new file mode 100644 index 0000000..ed93ecd --- /dev/null +++ b/src/HappyNotes.Services/interfaces/ITextToImageService.cs @@ -0,0 +1,14 @@ +namespace HappyNotes.Services.interfaces; + +public interface ITextToImageService +{ + /// + /// Generate an image from text content (for long messages) + /// Returns image bytes that can be uploaded to any platform + /// + Task GenerateImageAsync( + string content, + bool isMarkdown = false, + int width = 600, + CancellationToken cancellationToken = default); +} diff --git a/tests/HappyNotes.Services.Tests/FanfouSyncHandlerTests.cs b/tests/HappyNotes.Services.Tests/FanfouSyncHandlerTests.cs new file mode 100644 index 0000000..0fa0fca --- /dev/null +++ b/tests/HappyNotes.Services.Tests/FanfouSyncHandlerTests.cs @@ -0,0 +1,329 @@ +using Api.Framework.Helper; +using Api.Framework.Models; +using HappyNotes.Entities; +using HappyNotes.Repositories.interfaces; +using HappyNotes.Services.interfaces; +using HappyNotes.Services.SyncQueue.Configuration; +using HappyNotes.Services.SyncQueue.Handlers; +using HappyNotes.Services.SyncQueue.Models; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; + +namespace HappyNotes.Services.Tests; + +public class FanfouSyncHandlerTests +{ + private Mock _mockFanfouStatusService; + private Mock _mockFanfouUserAccountCacheService; + private Mock _mockTextToImageService; + private Mock _mockNoteRepository; + private Mock> _mockSyncQueueOptions; + private Mock> _mockJwtConfig; + private Mock> _mockLogger; + private FanfouSyncHandler _fanfouSyncHandler; + + private const string TestConsumerKey = "test_consumer_key"; + private const string TestConsumerSecret = "test_consumer_secret"; + private const string TestAccessToken = "test_access_token"; + private const string TestAccessTokenSecret = "test_access_token_secret"; + private const string TestJwtKey = "test_key_1234567890123456"; + + [SetUp] + public void Setup() + { + _mockFanfouStatusService = new Mock(); + _mockFanfouUserAccountCacheService = new Mock(); + _mockTextToImageService = new Mock(); + _mockNoteRepository = new Mock(); + _mockSyncQueueOptions = new Mock>(); + _mockJwtConfig = new Mock>(); + _mockLogger = new Mock>(); + + var syncQueueOptions = new SyncQueueOptions(); + _mockSyncQueueOptions.Setup(x => x.Value).Returns(syncQueueOptions); + + var jwtConfig = new JwtConfig { SymmetricSecurityKey = TestJwtKey }; + _mockJwtConfig.Setup(x => x.Value).Returns(jwtConfig); + + _fanfouSyncHandler = new FanfouSyncHandler( + _mockFanfouStatusService.Object, + _mockFanfouUserAccountCacheService.Object, + _mockTextToImageService.Object, + _mockNoteRepository.Object, + _mockSyncQueueOptions.Object, + _mockJwtConfig.Object, + _mockLogger.Object + ); + } + + private SyncTask CreateSyncTask(string action, FanfouSyncPayload payload, long noteId = 123, long userId = 1) + { + var task = SyncTask.Create("fanfou", action, noteId, userId, payload); + return new SyncTask + { + Id = task.Id, + Service = task.Service, + Action = task.Action, + EntityId = task.EntityId, + UserId = task.UserId, + Payload = task.Payload, + AttemptCount = task.AttemptCount, + CreatedAt = task.CreatedAt, + ScheduledFor = task.ScheduledFor, + Metadata = task.Metadata + }; + } + + private void SetupFanfouUserAccount(long userId, long userAccountId) + { + var fanfouUserAccounts = new List + { + new() + { + Id = userAccountId, + UserId = userId, + Username = "testuser", + ConsumerKey = TextEncryptionHelper.Encrypt(TestConsumerKey, TestJwtKey), + ConsumerSecret = TextEncryptionHelper.Encrypt(TestConsumerSecret, TestJwtKey), + AccessToken = TextEncryptionHelper.Encrypt(TestAccessToken, TestJwtKey), + AccessTokenSecret = TextEncryptionHelper.Encrypt(TestAccessTokenSecret, TestJwtKey) + } + }; + + _mockFanfouUserAccountCacheService + .Setup(s => s.GetAsync(userId)) + .ReturnsAsync(fanfouUserAccounts); + } + + #region CREATE Action Tests + + [Test] + public async Task ProcessCreateAction_WithShortContent_ShouldSendStatus() + { + // Arrange + var payload = new FanfouSyncPayload + { + FullContent = "Short test message", + UserAccountId = 1, + IsMarkdown = false + }; + + var task = CreateSyncTask("CREATE", payload); + + SetupFanfouUserAccount(1, 1); + _mockFanfouStatusService + .Setup(s => s.SendStatusAsync(TestConsumerKey, TestConsumerSecret, TestAccessToken, TestAccessTokenSecret, payload.FullContent, It.IsAny())) + .ReturnsAsync("status123"); + + var testNote = new Note { Id = 123, FanfouStatusIds = null }; + _mockNoteRepository + .Setup(r => r.GetFirstOrDefaultAsync(It.IsAny>>(), null)) + .ReturnsAsync(testNote); + + // Act + var result = await _fanfouSyncHandler.ProcessAsync(task, CancellationToken.None); + + // Assert + Assert.That(result.IsSuccess, Is.True); + _mockFanfouStatusService.Verify(s => s.SendStatusAsync(TestConsumerKey, TestConsumerSecret, TestAccessToken, TestAccessTokenSecret, payload.FullContent, It.IsAny()), Times.Once); + _mockNoteRepository.Verify(r => r.UpdateAsync( + It.IsAny>>(), + It.IsAny>>() + ), Times.Once); + } + + [Test] + public async Task ProcessCreateAction_WithLongContent_ShouldGenerateImageAndSendPhoto() + { + // Arrange + var longContent = new string('a', 200); // > 140 chars + var payload = new FanfouSyncPayload + { + FullContent = longContent, + UserAccountId = 1, + IsMarkdown = false + }; + + var task = CreateSyncTask("CREATE", payload); + var imageData = new byte[] { 0x01, 0x02, 0x03 }; + + SetupFanfouUserAccount(1, 1); + _mockTextToImageService + .Setup(s => s.GenerateImageAsync(longContent, false, 600, It.IsAny())) + .ReturnsAsync(imageData); + _mockFanfouStatusService + .Setup(s => s.SendStatusWithPhotoAsync(TestConsumerKey, TestConsumerSecret, TestAccessToken, TestAccessTokenSecret, imageData, null, It.IsAny())) + .ReturnsAsync("status456"); + + var testNote = new Note { Id = 123, FanfouStatusIds = null }; + _mockNoteRepository + .Setup(r => r.GetFirstOrDefaultAsync(It.IsAny>>(), null)) + .ReturnsAsync(testNote); + + // Act + var result = await _fanfouSyncHandler.ProcessAsync(task, CancellationToken.None); + + // Assert + Assert.That(result.IsSuccess, Is.True); + _mockTextToImageService.Verify(s => s.GenerateImageAsync(longContent, false, 600, It.IsAny()), Times.Once); + _mockFanfouStatusService.Verify(s => s.SendStatusWithPhotoAsync(TestConsumerKey, TestConsumerSecret, TestAccessToken, TestAccessTokenSecret, imageData, null, It.IsAny()), Times.Once); + } + + #endregion + + #region DELETE Action Tests + + [Test] + public async Task ProcessDeleteAction_ShouldDeleteStatusAndUpdateNote() + { + // Arrange + var payload = new FanfouSyncPayload + { + UserAccountId = 1, + StatusId = "status123" + }; + + var task = CreateSyncTask("DELETE", payload); + + SetupFanfouUserAccount(1, 1); + _mockFanfouStatusService + .Setup(s => s.DeleteStatusAsync(TestConsumerKey, TestConsumerSecret, TestAccessToken, TestAccessTokenSecret, payload.StatusId, It.IsAny())) + .Returns(Task.CompletedTask); + + var testNote = new Note { Id = 123, FanfouStatusIds = $"1:{payload.StatusId}" }; + _mockNoteRepository + .Setup(r => r.GetFirstOrDefaultAsync(It.IsAny>>(), null)) + .ReturnsAsync(testNote); + + // Act + var result = await _fanfouSyncHandler.ProcessAsync(task, CancellationToken.None); + + // Assert + Assert.That(result.IsSuccess, Is.True); + _mockFanfouStatusService.Verify(s => s.DeleteStatusAsync(TestConsumerKey, TestConsumerSecret, TestAccessToken, TestAccessTokenSecret, payload.StatusId, It.IsAny()), Times.Once); + _mockNoteRepository.Verify(r => r.UpdateAsync( + It.IsAny>>(), + It.IsAny>>() + ), Times.Once); + } + + [Test] + public async Task ProcessDeleteAction_WithoutStatusId_ShouldReturnFailure() + { + // Arrange + var payload = new FanfouSyncPayload + { + UserAccountId = 1, + StatusId = null // Missing StatusId + }; + + var task = CreateSyncTask("DELETE", payload); + + SetupFanfouUserAccount(1, 1); + + // Act + var result = await _fanfouSyncHandler.ProcessAsync(task, CancellationToken.None); + + // Assert + Assert.That(result.IsSuccess, Is.False); + Assert.That(result.ErrorMessage, Does.Contain("StatusId is required")); + } + + #endregion + + #region Error Handling Tests + + [Test] + public async Task ProcessAsync_WithInvalidPayload_ShouldReturnFailure() + { + // Arrange + var invalidTask = new SyncTask + { + Id = Guid.NewGuid().ToString(), + Service = "fanfou", + Action = "CREATE", + EntityId = 123, + UserId = 1, + Payload = "invalid_payload" + }; + + SetupFanfouUserAccount(1, 1); + + // Act + var result = await _fanfouSyncHandler.ProcessAsync(invalidTask, CancellationToken.None); + + // Assert + Assert.That(result.IsSuccess, Is.False); + Assert.That(result.ErrorMessage, Does.Contain("Invalid payload type")); + Assert.That(result.ShouldRetry, Is.False); + } + + [Test] + public async Task ProcessAsync_WithNoFanfouAccount_ShouldReturnFailure() + { + // Arrange + var payload = new FanfouSyncPayload + { + FullContent = "Test message", + UserAccountId = 1, + IsMarkdown = false + }; + + var task = CreateSyncTask("CREATE", payload); + + _mockFanfouUserAccountCacheService + .Setup(s => s.GetAsync(1)) + .ReturnsAsync(new List()); + + // Act + var result = await _fanfouSyncHandler.ProcessAsync(task, CancellationToken.None); + + // Assert + Assert.That(result.IsSuccess, Is.False); + Assert.That(result.ErrorMessage, Does.Contain("No Fanfou user account found")); + Assert.That(result.ShouldRetry, Is.False); + } + + [Test] + public async Task ProcessAsync_WithUnknownAction_ShouldReturnFailure() + { + // Arrange + var payload = new FanfouSyncPayload + { + FullContent = "Test message", + UserAccountId = 1, + IsMarkdown = false + }; + + var task = CreateSyncTask("UNKNOWN_ACTION", payload); + + SetupFanfouUserAccount(1, 1); + + // Act + var result = await _fanfouSyncHandler.ProcessAsync(task, CancellationToken.None); + + // Assert + Assert.That(result.IsSuccess, Is.False); + Assert.That(result.ErrorMessage, Does.Contain("Unknown action: UNKNOWN_ACTION")); + } + + #endregion + + #region Retry Delay Tests + + [Test] + public void CalculateRetryDelay_ShouldUseExponentialBackoff() + { + // Act & Assert + var delay1 = _fanfouSyncHandler.CalculateRetryDelay(1); + var delay2 = _fanfouSyncHandler.CalculateRetryDelay(2); + var delay3 = _fanfouSyncHandler.CalculateRetryDelay(3); + + // Exponential backoff: each delay should be longer than the previous + Assert.That(delay2.TotalSeconds, Is.GreaterThan(delay1.TotalSeconds)); + Assert.That(delay3.TotalSeconds, Is.GreaterThan(delay2.TotalSeconds)); + } + + #endregion +} diff --git a/tests/HappyNotes.Services.Tests/FanfouSyncNoteServiceTests.cs b/tests/HappyNotes.Services.Tests/FanfouSyncNoteServiceTests.cs new file mode 100644 index 0000000..9775a22 --- /dev/null +++ b/tests/HappyNotes.Services.Tests/FanfouSyncNoteServiceTests.cs @@ -0,0 +1,213 @@ +using Api.Framework.Helper; +using Api.Framework.Models; +using HappyNotes.Common.Enums; +using HappyNotes.Entities; +using HappyNotes.Repositories.interfaces; +using HappyNotes.Services.interfaces; +using HappyNotes.Services.SyncQueue.Interfaces; +using HappyNotes.Services.SyncQueue.Models; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; + +namespace HappyNotes.Services.Tests; + +public class FanfouSyncNoteServiceTests +{ + private Mock _mockNoteRepository; + private Mock _mockFanfouUserAccountCacheService; + private Mock _mockSyncQueueService; + private Mock> _mockJwtConfig; + private Mock> _mockLogger; + private FanfouSyncNoteService _fanfouSyncNoteService; + + [SetUp] + public void Setup() + { + _mockNoteRepository = new Mock(); + _mockFanfouUserAccountCacheService = new Mock(); + _mockSyncQueueService = new Mock(); + _mockJwtConfig = new Mock>(); + _mockLogger = new Mock>(); + + var jwtConfig = new JwtConfig { SymmetricSecurityKey = "test_key" }; + _mockJwtConfig.Setup(x => x.Value).Returns(jwtConfig); + + _fanfouSyncNoteService = new FanfouSyncNoteService( + _mockNoteRepository.Object, + _mockFanfouUserAccountCacheService.Object, + _mockJwtConfig.Object, + _mockSyncQueueService.Object, + _mockLogger.Object + ); + } + + [TestCase("test note", true, FanfouSyncType.All, "", true)] + [TestCase("test note", false, FanfouSyncType.All, "", true)] + [TestCase("test note", false, FanfouSyncType.PublicOnly, "", true)] + [TestCase("test note", true, FanfouSyncType.PublicOnly, "", false)] + [TestCase("test note", true, FanfouSyncType.TagFanfouOnly, "fanfou", true)] + [TestCase("test note", false, FanfouSyncType.TagFanfouOnly, "fanfou", true)] + [TestCase("test note", false, FanfouSyncType.TagFanfouOnly, "", false)] + [TestCase("test note", true, FanfouSyncType.TagFanfouOnly, "", false)] + public async Task SyncNewNote_ShouldSyncNoteToFanfou(string fullContent, bool isPrivate, + FanfouSyncType syncType, string tag, bool shouldSync) + { + // Arrange + var note = new Note + { + UserId = 1, + IsPrivate = isPrivate, + TagList = [tag,] + }; + + var fanfouUserAccounts = new List + { + new() + { + Id = 1, + UserId = 1, + Username = "testuser", + ConsumerKey = TextEncryptionHelper.Encrypt("test_key", "test_key"), + ConsumerSecret = TextEncryptionHelper.Encrypt("test_secret", "test_key"), + AccessToken = TextEncryptionHelper.Encrypt("test_token", "test_key"), + AccessTokenSecret = TextEncryptionHelper.Encrypt("test_token_secret", "test_key"), + SyncType = syncType + } + }; + + _mockFanfouUserAccountCacheService + .Setup(s => s.GetAsync(note.UserId)) + .ReturnsAsync(fanfouUserAccounts); + + // Act + await _fanfouSyncNoteService.SyncNewNote(note, fullContent); + + // Assert - Verify queue operation + if (shouldSync) + { + _mockSyncQueueService.Verify(s => s.EnqueueAsync("fanfou", + It.IsAny>()), + Times.Once); + } + else + { + _mockSyncQueueService.Verify(s => s.EnqueueAsync(It.IsAny(), It.IsAny>()), Times.Never); + } + } + + [TestCase("Short note", false, true)] + [TestCase("A very long note that exceeds 140 characters limit for Fanfou status update and requires image generation to preserve the full content", true, true)] + public async Task SyncNewNote_ShouldEnqueueWithOriginalContent(string fullContent, bool isLong, bool shouldSync) + { + // Arrange + var note = new Note + { + Id = 100, + UserId = 1, + IsPrivate = false, + TagList = [] + }; + + var fanfouUserAccounts = new List + { + new() + { + Id = 1, + UserId = 1, + Username = "testuser", + ConsumerKey = TextEncryptionHelper.Encrypt("test_key", "test_key"), + ConsumerSecret = TextEncryptionHelper.Encrypt("test_secret", "test_key"), + AccessToken = TextEncryptionHelper.Encrypt("test_token", "test_key"), + AccessTokenSecret = TextEncryptionHelper.Encrypt("test_token_secret", "test_key"), + SyncType = FanfouSyncType.All + } + }; + + _mockFanfouUserAccountCacheService + .Setup(s => s.GetAsync(note.UserId)) + .ReturnsAsync(fanfouUserAccounts); + + // Act + await _fanfouSyncNoteService.SyncNewNote(note, fullContent); + + // Assert - The original content should be enqueued + _mockSyncQueueService.Verify(s => s.EnqueueAsync("fanfou", + It.IsAny>()), + shouldSync ? Times.Once : Times.Never); + } + + [Test] + public async Task SyncDeleteNote_WithFanfouStatusIds_ShouldEnqueueDeleteTasks() + { + // Arrange + var note = new Note + { + Id = 123, + UserId = 1, + FanfouStatusIds = "1:status123,2:status456" + }; + + var fanfouUserAccounts = new List + { + new() + { + Id = 1, + UserId = 1, + Username = "testuser", + ConsumerKey = TextEncryptionHelper.Encrypt("test_key", "test_key"), + ConsumerSecret = TextEncryptionHelper.Encrypt("test_secret", "test_key"), + AccessToken = TextEncryptionHelper.Encrypt("test_token", "test_key"), + AccessTokenSecret = TextEncryptionHelper.Encrypt("test_token_secret", "test_key"), + SyncType = FanfouSyncType.All + }, + new() + { + Id = 2, + UserId = 1, + Username = "testuser2", + ConsumerKey = TextEncryptionHelper.Encrypt("test_key2", "test_key"), + ConsumerSecret = TextEncryptionHelper.Encrypt("test_secret2", "test_key"), + AccessToken = TextEncryptionHelper.Encrypt("test_token2", "test_key"), + AccessTokenSecret = TextEncryptionHelper.Encrypt("test_token_secret2", "test_key"), + SyncType = FanfouSyncType.All + } + }; + + _mockFanfouUserAccountCacheService + .Setup(s => s.GetAsync(note.UserId)) + .ReturnsAsync(fanfouUserAccounts); + + // Act + await _fanfouSyncNoteService.SyncDeleteNote(note); + + // Assert - Should enqueue DELETE tasks for both statuses + _mockSyncQueueService.Verify(s => s.EnqueueAsync("fanfou", + It.IsAny>()), + Times.Exactly(2)); + } + + [Test] + public async Task SyncDeleteNote_WithoutFanfouStatusIds_ShouldNotEnqueue() + { + // Arrange + var note = new Note + { + Id = 123, + UserId = 1, + FanfouStatusIds = null + }; + + var fanfouUserAccounts = new List(); + + _mockFanfouUserAccountCacheService + .Setup(s => s.GetAsync(note.UserId)) + .ReturnsAsync(fanfouUserAccounts); + + // Act + await _fanfouSyncNoteService.SyncDeleteNote(note); + + // Assert - Should not enqueue any DELETE tasks + _mockSyncQueueService.Verify(s => s.EnqueueAsync(It.IsAny(), It.IsAny>()), Times.Never); + } +} From dad7a32c7b3687db0f424fa0075860d631307e53 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sat, 17 Jan 2026 03:45:49 +0000 Subject: [PATCH 2/2] style: auto-format code with dotnet format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with GitHub Actions Co-Authored-By: GitHub Actions --- .../Controllers/FanfouAuthController.cs | 2 +- .../Controllers/SyncQueueAdminController.cs | 4 ++-- .../Configuration/SyncQueueOptions.cs | 4 ++-- .../SyncQueue/Handlers/TelegramSyncHandler.cs | 2 +- .../SyncQueue/Interfaces/ISyncHandler.cs | 8 +++---- .../SyncQueue/Interfaces/ISyncQueueService.cs | 22 +++++++++---------- .../SyncQueue/Models/SyncTask.cs | 6 ++--- .../SyncQueue/Models/TelegramSyncPayload.cs | 2 +- .../SyncQueue/RedisSyncQueueServiceTests.cs | 10 ++++----- 9 files changed, 30 insertions(+), 30 deletions(-) diff --git a/src/HappyNotes.Api/Controllers/FanfouAuthController.cs b/src/HappyNotes.Api/Controllers/FanfouAuthController.cs index 5bdf9af..4e4d7cf 100644 --- a/src/HappyNotes.Api/Controllers/FanfouAuthController.cs +++ b/src/HappyNotes.Api/Controllers/FanfouAuthController.cs @@ -1,3 +1,4 @@ +using System.Web; using Api.Framework; using Api.Framework.Helper; using Api.Framework.Models; @@ -9,7 +10,6 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Newtonsoft.Json; -using System.Web; namespace HappyNotes.Api.Controllers; diff --git a/src/HappyNotes.Api/Controllers/SyncQueueAdminController.cs b/src/HappyNotes.Api/Controllers/SyncQueueAdminController.cs index 89869cc..d0279f8 100644 --- a/src/HappyNotes.Api/Controllers/SyncQueueAdminController.cs +++ b/src/HappyNotes.Api/Controllers/SyncQueueAdminController.cs @@ -1,6 +1,6 @@ +using HappyNotes.Common; using HappyNotes.Services.SyncQueue.Interfaces; using HappyNotes.Services.SyncQueue.Models; -using HappyNotes.Common; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; @@ -165,4 +165,4 @@ public async Task HealthCheck() }); } } -} \ No newline at end of file +} diff --git a/src/HappyNotes.Services/SyncQueue/Configuration/SyncQueueOptions.cs b/src/HappyNotes.Services/SyncQueue/Configuration/SyncQueueOptions.cs index 805cbb5..4dd2e9e 100644 --- a/src/HappyNotes.Services/SyncQueue/Configuration/SyncQueueOptions.cs +++ b/src/HappyNotes.Services/SyncQueue/Configuration/SyncQueueOptions.cs @@ -3,7 +3,7 @@ namespace HappyNotes.Services.SyncQueue.Configuration; public class SyncQueueOptions { public const string SectionName = "SyncQueue"; - + public RedisOptions Redis { get; set; } = new(); public ProcessingOptions Processing { get; set; } = new(); public Dictionary Handlers { get; set; } = new(); @@ -31,4 +31,4 @@ public class HandlerOptions public int BaseDelaySeconds { get; set; } = 60; public double BackoffMultiplier { get; set; } = 2.0; public int MaxDelayMinutes { get; set; } = 60; -} \ No newline at end of file +} diff --git a/src/HappyNotes.Services/SyncQueue/Handlers/TelegramSyncHandler.cs b/src/HappyNotes.Services/SyncQueue/Handlers/TelegramSyncHandler.cs index 5e28a4b..30354a8 100644 --- a/src/HappyNotes.Services/SyncQueue/Handlers/TelegramSyncHandler.cs +++ b/src/HappyNotes.Services/SyncQueue/Handlers/TelegramSyncHandler.cs @@ -263,7 +263,7 @@ private async Task ProcessCreateAction(TelegramSyncPayload payload, string botTo } // Update note with the new message ID - await AddMessageIdToNote(task.EntityId, payload.ChannelId, messageId); + await AddMessageIdToNote(task.EntityId, payload.ChannelId, messageId); _logger.LogDebug("Successfully added message {MessageId} to note {NoteId} TelegramMessageIds", messageId, task.EntityId); diff --git a/src/HappyNotes.Services/SyncQueue/Interfaces/ISyncHandler.cs b/src/HappyNotes.Services/SyncQueue/Interfaces/ISyncHandler.cs index 49b8160..7f0e196 100644 --- a/src/HappyNotes.Services/SyncQueue/Interfaces/ISyncHandler.cs +++ b/src/HappyNotes.Services/SyncQueue/Interfaces/ISyncHandler.cs @@ -8,19 +8,19 @@ public interface ISyncHandler /// The service name this handler processes (e.g., "telegram", "mastodon") /// string ServiceName { get; } - + /// /// Maximum number of retry attempts /// int MaxRetryAttempts { get; } - + /// /// Process a sync task /// Task ProcessAsync(SyncTask task, CancellationToken cancellationToken); - + /// /// Calculate retry delay based on attempt count /// TimeSpan CalculateRetryDelay(int attemptCount); -} \ No newline at end of file +} diff --git a/src/HappyNotes.Services/SyncQueue/Interfaces/ISyncQueueService.cs b/src/HappyNotes.Services/SyncQueue/Interfaces/ISyncQueueService.cs index 1d4dfb3..b6b7e8a 100644 --- a/src/HappyNotes.Services/SyncQueue/Interfaces/ISyncQueueService.cs +++ b/src/HappyNotes.Services/SyncQueue/Interfaces/ISyncQueueService.cs @@ -8,54 +8,54 @@ public interface ISyncQueueService /// Enqueue a task for processing /// Task EnqueueAsync(string service, SyncTask task); - + /// /// Dequeue a task from the main queue /// Task?> DequeueAsync(string service, CancellationToken cancellationToken); - + /// /// Schedule a task for retry with delay /// Task ScheduleRetryAsync(string service, SyncTask task, TimeSpan delay); - + /// /// Move a task to the failed queue /// Task MoveToFailedAsync(string service, SyncTask task, string error); - + /// /// Get queue statistics for a service /// Task GetStatsAsync(string service); - + /// /// Retry all failed tasks for a service /// Task RetryFailedTasksAsync(string service); - + /// /// Clear all tasks from a service queue /// Task ClearQueueAsync(string service); - + /// /// Mark a task as processing /// Task MarkAsProcessingAsync(string service, SyncTask task); - + /// /// Remove a task from processing queue /// Task RemoveFromProcessingAsync(string service, SyncTask task); - + /// /// Remove a task from processing queue and update success statistics /// Task RemoveFromProcessingAsyncOnSuccess(string service, SyncTask task); - + /// /// Recover expired tasks from processing queue /// Task RecoverExpiredTasksAsync(string service); -} \ No newline at end of file +} diff --git a/src/HappyNotes.Services/SyncQueue/Models/SyncTask.cs b/src/HappyNotes.Services/SyncQueue/Models/SyncTask.cs index b96460b..0284369 100644 --- a/src/HappyNotes.Services/SyncQueue/Models/SyncTask.cs +++ b/src/HappyNotes.Services/SyncQueue/Models/SyncTask.cs @@ -40,8 +40,8 @@ public class SyncResult public TimeSpan? CustomRetryDelay { get; set; } public static SyncResult Success() => new() { IsSuccess = true }; - - public static SyncResult Failure(string errorMessage, bool shouldRetry = true, TimeSpan? customRetryDelay = null) => + + public static SyncResult Failure(string errorMessage, bool shouldRetry = true, TimeSpan? customRetryDelay = null) => new() { IsSuccess = false, @@ -62,4 +62,4 @@ public class QueueStats public DateTime LastFailedAt { get; set; } public long TotalProcessed { get; set; } public long TotalFailed { get; set; } -} \ No newline at end of file +} diff --git a/src/HappyNotes.Services/SyncQueue/Models/TelegramSyncPayload.cs b/src/HappyNotes.Services/SyncQueue/Models/TelegramSyncPayload.cs index 1f9fe8c..7c3582d 100644 --- a/src/HappyNotes.Services/SyncQueue/Models/TelegramSyncPayload.cs +++ b/src/HappyNotes.Services/SyncQueue/Models/TelegramSyncPayload.cs @@ -9,4 +9,4 @@ public class TelegramSyncPayload public int? MessageId { get; set; } // For UPDATE/DELETE operations public bool IsMarkdown { get; set; } public Dictionary Metadata { get; set; } = new(); -} \ No newline at end of file +} diff --git a/tests/HappyNotes.Services.Tests/SyncQueue/RedisSyncQueueServiceTests.cs b/tests/HappyNotes.Services.Tests/SyncQueue/RedisSyncQueueServiceTests.cs index 6c5ba88..bb2e719 100644 --- a/tests/HappyNotes.Services.Tests/SyncQueue/RedisSyncQueueServiceTests.cs +++ b/tests/HappyNotes.Services.Tests/SyncQueue/RedisSyncQueueServiceTests.cs @@ -21,7 +21,7 @@ public class RedisSyncQueueServiceTests public void Setup() { // Get Redis connection string from environment variable - _redisConnectionString = Environment.GetEnvironmentVariable("REDIS_CONNECTION_STRING") + _redisConnectionString = Environment.GetEnvironmentVariable("REDIS_CONNECTION_STRING") ?? Environment.GetEnvironmentVariable("TEST_REDIS_CONNECTION_STRING") ?? "localhost:6379"; @@ -30,7 +30,7 @@ public void Setup() { _redis = ConnectionMultiplexer.Connect(_redisConnectionString); _database = _redis.GetDatabase(15); // Use database 15 for tests - + var options = Options.Create(new SyncQueueOptions { Redis = new RedisOptions @@ -40,7 +40,7 @@ public void Setup() KeyPrefix = "test:sync:" } }); - + var logger = new LoggerFactory().CreateLogger(); _queueService = new RedisSyncQueueService(_redis, options, logger, TimeProvider.System); } @@ -69,7 +69,7 @@ public void TearDown() } } } - + _redis?.Dispose(); } catch @@ -172,4 +172,4 @@ public async Task ClearQueueAsync_ShouldClearAllQueues() Assert.That(stats.PendingCount, Is.EqualTo(0)); Assert.That(stats.FailedCount, Is.EqualTo(0)); } -} \ No newline at end of file +}