Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions TelegramSearchBot.Test/Service/Common/UrlDisplayServiceTests.cs
Original file line number Diff line number Diff line change
@@ -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?...](<https://example.com/some/really/long/path/to/resource-name?query=1&another=2>)", 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);
}
}
}
26 changes: 22 additions & 4 deletions TelegramSearchBot/Controller/AI/QR/AutoQRController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -29,6 +30,7 @@ public class AutoQRController : IOnUpdate, IProcessPhoto {
private readonly ILogger<AutoQRController> _logger;
private readonly IMediator _mediator;
private readonly ISendMessageService _sendMessageService;
private readonly UrlDisplayService _urlDisplayService;
private readonly MessageExtensionService MessageExtensionService;

public List<Type> Dependencies => new List<Type>() { typeof(DownloadPhotoController), typeof(MessageController) };
Expand All @@ -39,13 +41,15 @@ public AutoQRController(
MessageService messageService,
IMediator mediator,
ISendMessageService sendMessageService,
UrlDisplayService urlDisplayService,
MessageExtensionService messageExtensionService
) {
_autoQRService = autoQRService;
_messageService = messageService;
_logger = logger;
_mediator = mediator;
_sendMessageService = sendMessageService;
_urlDisplayService = urlDisplayService;
MessageExtensionService = messageExtensionService;
}

Expand All @@ -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);
Expand Down
8 changes: 6 additions & 2 deletions TelegramSearchBot/Interface/ISendMessageService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down
29 changes: 18 additions & 11 deletions TelegramSearchBot/Service/BotAPI/SendMessageService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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."); }
Expand All @@ -62,46 +65,50 @@ 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);
}
} catch (Exception ex) {
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);
}
}
Expand Down
94 changes: 94 additions & 0 deletions TelegramSearchBot/Service/Common/UrlDisplayService.cs
Original file line number Diff line number Diff line change
@@ -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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Default truncation length disagrees with PR description.

The PR description (and linked issue context) states the default truncation is 80 characters, but DefaultMaxDisplayLength = 60. Please reconcile — either update the constant to 80 or adjust the PR description / docs to reflect 60.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@TelegramSearchBot/Service/Common/UrlDisplayService.cs` at line 9, The
DefaultMaxDisplayLength constant in UrlDisplayService (DefaultMaxDisplayLength =
60) conflicts with the PR description that specifies an 80-character default;
update the constant to 80 to match the PR/issue or, if the intended default is
60, update the PR description/docs to state 60 — typically change the value of
DefaultMaxDisplayLength in UrlDisplayService to 80 and run related tests/usage
checks to ensure consistent behavior across methods that reference
DefaultMaxDisplayLength.


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;
}
Comment on lines +17 to +26

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

URL containing > or whitespace would break the [label](<url>) output.

The angle-bracket link form terminates at the first >. If the QR payload is a URI that contains %3E decoded somewhere upstream, or any character that closes the bracketed form (>, newline), the resulting Markdown is malformed. Since TryGetStandaloneUrl returns the raw trimmed text without re-encoding, consider either using the non-bracketed form [label](url) (URLs don’t contain ) unencoded) or escaping/rejecting URLs that contain >/whitespace. Minor because real http(s) URLs rarely contain raw >, but worth a defensive check.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@TelegramSearchBot/Service/Common/UrlDisplayService.cs` around lines 17 - 26,
TryFormatUrlOnlyMessage currently emits the angle-bracket link form which breaks
if the URL contains '>' or whitespace; update TryFormatUrlOnlyMessage to use the
non-bracketed markdown form instead (markdownText = $"[打开链接:{label}]({url})")
after verifying the url returned by TryGetStandaloneUrl contains no unencoded
')' (or other raw whitespace/'>') — if such characters are present, either
reject the URL (return false) or re-encode/escape them before emitting. Keep
label generation via BuildDisplayLabel and EscapeMarkdownLinkText unchanged but
add the validation/guard against raw '>'/whitespace in the url inside
TryFormatUrlOnlyMessage.


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;
}
Comment on lines +60 to +73

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Restrict TryGetStandaloneUrl to safe URL schemes.

Uri.TryCreate(..., UriKind.Absolute, ...) accepts any absolute URI, including javascript:, data:, file:, ftp:, mailto:, etc. Since a QR code payload is untrusted input and the output of this method is rendered as a clickable Markdown link sent to the chat, any non-http(s) scheme (e.g., file:///etc/passwd, javascript:...) will become a one-tap link for every chat member. Consider whitelisting http/https only.

🛡️ Proposed fix
-            var trimmed = text.Trim();
-            if (!Uri.TryCreate(trimmed, UriKind.Absolute, out _)) {
-                return false;
-            }
-
-            url = trimmed;
-            return true;
+            var trimmed = text.Trim();
+            if (!Uri.TryCreate(trimmed, UriKind.Absolute, out var parsed)) {
+                return false;
+            }
+            if (parsed.Scheme != Uri.UriSchemeHttp && parsed.Scheme != Uri.UriSchemeHttps) {
+                return false;
+            }
+
+            url = trimmed;
+            return true;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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 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 var parsed)) {
return false;
}
if (parsed.Scheme != Uri.UriSchemeHttp && parsed.Scheme != Uri.UriSchemeHttps) {
return false;
}
url = trimmed;
return true;
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@TelegramSearchBot/Service/Common/UrlDisplayService.cs` around lines 60 - 73,
The TryGetStandaloneUrl method currently accepts any absolute URI; restrict it
to only allow http and https schemes by changing the Uri.TryCreate call to
capture the resulting Uri (e.g., Uri.TryCreate(trimmed, UriKind.Absolute, out
var uri)) and then verify uri.Scheme equals Uri.UriSchemeHttp or
Uri.UriSchemeHttps (case-insensitive) before returning true; leave url = trimmed
only when the scheme check passes and return false otherwise so unsafe schemes
like javascript:, file:, data:, mailto:, ftp:, etc. are rejected.


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) + "...";
}
}
}
Loading