diff --git a/TelegramSearchBot.Test/Service/Common/UrlDisplayServiceTests.cs b/TelegramSearchBot.Test/Service/Common/UrlDisplayServiceTests.cs new file mode 100644 index 00000000..bd56a2fb --- /dev/null +++ b/TelegramSearchBot.Test/Service/Common/UrlDisplayServiceTests.cs @@ -0,0 +1,37 @@ +using TelegramSearchBot.Service.Common; +using Xunit; + +namespace TelegramSearchBot.Test.Service.Common { + public class UrlDisplayServiceTests { + private readonly UrlDisplayService _service = new(); + + [Fact] + public void IsUrlOnlyMessage_WithAbsoluteUrl_ReturnsTrue() { + Assert.True(_service.IsUrlOnlyMessage(" https://example.com/path?q=1 ")); + } + + [Fact] + public void IsUrlOnlyMessage_WithMixedText_ReturnsFalse() { + Assert.False(_service.IsUrlOnlyMessage("QR result: https://example.com/path?q=1")); + } + + [Fact] + public void TryFormatUrlOnlyMessage_WithLongUrl_ReturnsMarkdownLink() { + var url = "https://example.com/some/really/long/path/to/resource-name?query=1&another=2"; + + var result = _service.TryFormatUrlOnlyMessage(url, out var markdownText); + + Assert.True(result); + Assert.Equal("[打开链接:example.com/resource-name?...]()", markdownText); + } + + [Fact] + public void BuildDisplayLabel_WithInvalidUrl_TruncatesRawText() { + var rawText = "not-a-valid-url-but-still-a-very-long-value-that-needs-shortening"; + + var label = _service.BuildDisplayLabel(rawText, 20); + + Assert.Equal("not-a-valid-url-b...", label); + } + } +} diff --git a/TelegramSearchBot/Controller/AI/QR/AutoQRController.cs b/TelegramSearchBot/Controller/AI/QR/AutoQRController.cs index 14e2c0b6..3f0674f6 100644 --- a/TelegramSearchBot/Controller/AI/QR/AutoQRController.cs +++ b/TelegramSearchBot/Controller/AI/QR/AutoQRController.cs @@ -20,6 +20,7 @@ using TelegramSearchBot.Model.Notifications; // Added for TextMessageReceivedNotification using TelegramSearchBot.Service.AI.QR; // Added for AutoQRService using TelegramSearchBot.Service.BotAPI; +using TelegramSearchBot.Service.Common; using TelegramSearchBot.Service.Storage; // Added for MessageService namespace TelegramSearchBot.Controller.AI.QR { @@ -29,6 +30,7 @@ public class AutoQRController : IOnUpdate, IProcessPhoto { private readonly ILogger _logger; private readonly IMediator _mediator; private readonly ISendMessageService _sendMessageService; + private readonly UrlDisplayService _urlDisplayService; private readonly MessageExtensionService MessageExtensionService; public List Dependencies => new List() { typeof(DownloadPhotoController), typeof(MessageController) }; @@ -39,6 +41,7 @@ public AutoQRController( MessageService messageService, IMediator mediator, ISendMessageService sendMessageService, + UrlDisplayService urlDisplayService, MessageExtensionService messageExtensionService ) { _autoQRService = autoQRService; @@ -46,6 +49,7 @@ MessageExtensionService messageExtensionService _logger = logger; _mediator = mediator; _sendMessageService = sendMessageService; + _urlDisplayService = urlDisplayService; MessageExtensionService = messageExtensionService; } @@ -71,10 +75,24 @@ public async Task ExecuteAsync(PipelineContext p) { // Add QR result to processing results p.ProcessingResults.Add($"[QR识别结果] {qrStr}"); - // 1. Original logic: Send the raw QR string back to the user. - await _sendMessageService.SendMessage(qrStr, e.Message.Chat.Id, e.Message.MessageId); - _logger.LogInformation("Sent raw QR content for {ChatId}/{MessageId}", e.Message.Chat.Id, e.Message.MessageId); - + // 1. Send compact clickable text for URL-only QR results to avoid flooding the chat. + if (_urlDisplayService.TryFormatUrlOnlyMessage(qrStr, out var formattedQrMessage)) { + await _sendMessageService.TrySendMessageWithFallback( + e.Message.Chat.Id, + 0, + formattedQrMessage, + ParseMode.Html, + e.Message.Chat.Id < 0, + e.Message.MessageId, + qrStr, + false, + disableLinkPreview: true, + plainTextFallbackOverride: qrStr); + _logger.LogInformation("Sent formatted QR URL for {ChatId}/{MessageId}", e.Message.Chat.Id, e.Message.MessageId); + } else { + await _sendMessageService.SendMessage(qrStr, e.Message.Chat.Id, e.Message.MessageId); + _logger.LogInformation("Sent raw QR content for {ChatId}/{MessageId}", e.Message.Chat.Id, e.Message.MessageId); + } // 2. Storing the raw QR content as a message. await MessageExtensionService.AddOrUpdateAsync(p.MessageDataId, "QR_Result", qrStr); diff --git a/TelegramSearchBot/Interface/ISendMessageService.cs b/TelegramSearchBot/Interface/ISendMessageService.cs index d4d17e84..ba5479de 100644 --- a/TelegramSearchBot/Interface/ISendMessageService.cs +++ b/TelegramSearchBot/Interface/ISendMessageService.cs @@ -19,7 +19,9 @@ Task TrySendMessageWithFallback( bool isGroup, int replyToMessageId, string initialContentForNewMessage, - bool isEdit); + bool isEdit, + bool disableLinkPreview = false, + string? plainTextFallbackOverride = null); Task AttemptFallbackSend( long chatId, @@ -28,7 +30,9 @@ Task AttemptFallbackSend( bool isGroup, int replyToMessageId, bool wasEditAttempt, - string initialFailureReason); + string initialFailureReason, + bool disableLinkPreview = false, + string? plainTextFallbackOverride = null); #endregion #region Standard Send Methods diff --git a/TelegramSearchBot/Service/BotAPI/SendMessageService.cs b/TelegramSearchBot/Service/BotAPI/SendMessageService.cs index aa7bd04e..64cbe62f 100644 --- a/TelegramSearchBot/Service/BotAPI/SendMessageService.cs +++ b/TelegramSearchBot/Service/BotAPI/SendMessageService.cs @@ -40,9 +40,12 @@ public SendMessageService(ITelegramBotClient botClient, SendMessage Send, ILogge #region Fallback and Formatting Helpers - public async Task TrySendMessageWithFallback(long chatId, int messageId, string originalMarkdownText, ParseMode preferredParseMode, bool isGroup, int replyToMessageId, string initialContentForNewMessage, bool isEdit) { + public async Task TrySendMessageWithFallback(long chatId, int messageId, string originalMarkdownText, ParseMode preferredParseMode, bool isGroup, int replyToMessageId, string initialContentForNewMessage, bool isEdit, bool disableLinkPreview = false, string? plainTextFallbackOverride = null) { string textToSend = originalMarkdownText; ParseMode currentParseMode = preferredParseMode; + var linkPreviewOptions = disableLinkPreview + ? new LinkPreviewOptions { IsDisabled = true } + : null; if (preferredParseMode == ParseMode.Html) { textToSend = MessageFormatHelper.ConvertMarkdownToTelegramHtml(originalMarkdownText); @@ -53,7 +56,7 @@ public async Task TrySendMessageWithFallback(long chatId, int messageId, string Message editedMessage = null; await Send.AddTask(async () => { editedMessage = await botClient.EditMessageText( - chatId: chatId, messageId: messageId, parseMode: currentParseMode, text: textToSend); + chatId: chatId, messageId: messageId, parseMode: currentParseMode, text: textToSend, linkPreviewOptions: linkPreviewOptions); }, isGroup); if (editedMessage != null && editedMessage.MessageId > 0) { logger.LogInformation($"Edited message {editedMessage.MessageId} successfully with {currentParseMode}."); } else { logger.LogWarning($"Editing message {messageId} with {currentParseMode} failed silently. Edit will be skipped."); } @@ -62,38 +65,42 @@ await Send.AddTask(async () => { await Send.AddTask(async () => { sentMsg = await botClient.SendMessage( chatId: chatId, text: textToSend, parseMode: currentParseMode, - replyParameters: new ReplyParameters() { MessageId = replyToMessageId }); + replyParameters: new ReplyParameters() { MessageId = replyToMessageId }, + linkPreviewOptions: linkPreviewOptions); }, isGroup); if (sentMsg != null && sentMsg.MessageId > 0) { logger.LogInformation($"Sent new message {sentMsg.MessageId} successfully with {currentParseMode}."); } else { logger.LogWarning($"Sending new message to {chatId} with {currentParseMode} failed silently. Attempting fallback."); - await AttemptFallbackSend(chatId, messageId, originalMarkdownText, isGroup, replyToMessageId, false, $"{currentParseMode} send failed silently"); + await AttemptFallbackSend(chatId, messageId, originalMarkdownText, isGroup, replyToMessageId, false, $"{currentParseMode} send failed silently", disableLinkPreview, plainTextFallbackOverride); } } } catch (ApiRequestException apiEx) when (apiEx.Message.Contains("can't parse entities") || apiEx.Message.Contains("unclosed tag") || apiEx.ErrorCode == 400) { if (isEdit) { logger.LogWarning(apiEx, $"Failed to edit message {messageId} with {currentParseMode} due to API error. Edit will be skipped."); } else { logger.LogWarning(apiEx, $"Failed to send new message to {chatId} with {currentParseMode} due to API error. Attempting fallback."); - await AttemptFallbackSend(chatId, messageId, originalMarkdownText, isGroup, replyToMessageId, false, apiEx.Message); + await AttemptFallbackSend(chatId, messageId, originalMarkdownText, isGroup, replyToMessageId, false, apiEx.Message, disableLinkPreview, plainTextFallbackOverride); } } catch (Exception ex) { if (isEdit) { logger.LogError(ex, $"An unexpected error occurred while editing message {messageId}. Edit will be skipped."); } else { logger.LogError(ex, $"An unexpected error occurred while sending new message to {chatId}. Attempting fallback."); - await AttemptFallbackSend(chatId, messageId, originalMarkdownText, isGroup, replyToMessageId, false, $"Unexpected error: {ex.Message}"); + await AttemptFallbackSend(chatId, messageId, originalMarkdownText, isGroup, replyToMessageId, false, $"Unexpected error: {ex.Message}", disableLinkPreview, plainTextFallbackOverride); } } } - public async Task AttemptFallbackSend(long chatId, int messageId, string originalMarkdownText, bool isGroup, int replyToMessageId, bool wasEditAttempt, string initialFailureReason) { - var plainText = MessageFormatHelper.ConvertToPlainText(originalMarkdownText); + public async Task AttemptFallbackSend(long chatId, int messageId, string originalMarkdownText, bool isGroup, int replyToMessageId, bool wasEditAttempt, string initialFailureReason, bool disableLinkPreview = false, string? plainTextFallbackOverride = null) { + var plainText = plainTextFallbackOverride ?? MessageFormatHelper.ConvertToPlainText(originalMarkdownText); + var linkPreviewOptions = disableLinkPreview + ? new LinkPreviewOptions { IsDisabled = true } + : null; try { if (wasEditAttempt) { await Send.AddTask(async () => { - var fallbackEditedMessage = await botClient.EditMessageText(chatId: chatId, messageId: messageId, text: plainText); + var fallbackEditedMessage = await botClient.EditMessageText(chatId: chatId, messageId: messageId, text: plainText, linkPreviewOptions: linkPreviewOptions); logger.LogInformation($"Successfully resent message {fallbackEditedMessage.MessageId} as plain text after Markdown failure ({initialFailureReason})."); }, isGroup); } else { await Send.AddTask(async () => { - var fallbackSentMsg = await botClient.SendMessage(chatId: chatId, text: plainText, replyParameters: new ReplyParameters() { MessageId = replyToMessageId }); + var fallbackSentMsg = await botClient.SendMessage(chatId: chatId, text: plainText, replyParameters: new ReplyParameters() { MessageId = replyToMessageId }, linkPreviewOptions: linkPreviewOptions); logger.LogInformation($"Successfully sent new message {fallbackSentMsg.MessageId} as plain text after Markdown failure ({initialFailureReason})."); }, isGroup); } @@ -101,7 +108,7 @@ await Send.AddTask(async () => { logger.LogError(ex, $"Failed to send message to {chatId} even as plain text. Initial failure: {initialFailureReason}."); if (!wasEditAttempt) { await Send.AddTask(async () => { - await botClient.SendMessage(chatId: chatId, text: "An error occurred while formatting the message.", replyParameters: new ReplyParameters() { MessageId = replyToMessageId }); + await botClient.SendMessage(chatId: chatId, text: "An error occurred while formatting the message.", replyParameters: new ReplyParameters() { MessageId = replyToMessageId }, linkPreviewOptions: linkPreviewOptions); }, isGroup); } } diff --git a/TelegramSearchBot/Service/Common/UrlDisplayService.cs b/TelegramSearchBot/Service/Common/UrlDisplayService.cs new file mode 100644 index 00000000..6bd2c6de --- /dev/null +++ b/TelegramSearchBot/Service/Common/UrlDisplayService.cs @@ -0,0 +1,94 @@ +using System; +using System.Linq; +using TelegramSearchBot.Attributes; +using TelegramSearchBot.Interface; + +namespace TelegramSearchBot.Service.Common { + [Injectable(Microsoft.Extensions.DependencyInjection.ServiceLifetime.Transient)] + public class UrlDisplayService : IService { + private const int DefaultMaxDisplayLength = 60; + + public string ServiceName => nameof(UrlDisplayService); + + public bool IsUrlOnlyMessage(string text) { + return TryGetStandaloneUrl(text, out _); + } + + public bool TryFormatUrlOnlyMessage(string text, out string markdownText) { + markdownText = string.Empty; + if (!TryGetStandaloneUrl(text, out var url)) { + return false; + } + + var label = EscapeMarkdownLinkText(BuildDisplayLabel(url)); + markdownText = $"[打开链接:{label}](<{url}>)"; + return true; + } + + public string BuildDisplayLabel(string url, int maxLength = DefaultMaxDisplayLength) { + if (string.IsNullOrWhiteSpace(url)) { + return string.Empty; + } + + var trimmedUrl = url.Trim(); + if (!Uri.TryCreate(trimmedUrl, UriKind.Absolute, out var uri) || string.IsNullOrWhiteSpace(uri.Host)) { + return Truncate(trimmedUrl, maxLength); + } + + var builder = uri.Host; + var lastSegment = uri.Segments + .Select(static segment => segment.Trim('/')) + .LastOrDefault(static segment => !string.IsNullOrWhiteSpace(segment)); + + if (!string.IsNullOrWhiteSpace(lastSegment)) { + builder += "/" + Uri.UnescapeDataString(lastSegment); + } else if (!string.IsNullOrWhiteSpace(uri.AbsolutePath) && uri.AbsolutePath != "/") { + builder += uri.AbsolutePath; + } + + if (!string.IsNullOrWhiteSpace(uri.Query)) { + builder += "?..."; + } + + if (!string.IsNullOrWhiteSpace(uri.Fragment)) { + builder += "#..."; + } + + return Truncate(builder, maxLength); + } + + private static bool TryGetStandaloneUrl(string text, out string url) { + url = string.Empty; + if (string.IsNullOrWhiteSpace(text)) { + return false; + } + + var trimmed = text.Trim(); + if (!Uri.TryCreate(trimmed, UriKind.Absolute, out _)) { + return false; + } + + url = trimmed; + return true; + } + + private static string EscapeMarkdownLinkText(string text) { + return text + .Replace("\\", "\\\\", StringComparison.Ordinal) + .Replace("[", "\\[", StringComparison.Ordinal) + .Replace("]", "\\]", StringComparison.Ordinal); + } + + private static string Truncate(string value, int maxLength) { + if (value.Length <= maxLength) { + return value; + } + + if (maxLength <= 3) { + return value.Substring(0, maxLength); + } + + return value.Substring(0, maxLength - 3) + "..."; + } + } +}