diff --git a/TelegramSearchBot.LLM.Test/McpToolHelperTestCollection.cs b/TelegramSearchBot.LLM.Test/McpToolHelperTestCollection.cs new file mode 100644 index 00000000..df367204 --- /dev/null +++ b/TelegramSearchBot.LLM.Test/McpToolHelperTestCollection.cs @@ -0,0 +1,8 @@ +using Xunit; + +namespace TelegramSearchBot.Test { + [CollectionDefinition(Name, DisableParallelization = true)] + public sealed class McpToolHelperTestCollection { + public const string Name = "McpToolHelper serial"; + } +} diff --git a/TelegramSearchBot.LLM.Test/Service/AI/LLM/McpToolHelperTests.cs b/TelegramSearchBot.LLM.Test/Service/AI/LLM/McpToolHelperTests.cs index 300a4ad9..9220c035 100644 --- a/TelegramSearchBot.LLM.Test/Service/AI/LLM/McpToolHelperTests.cs +++ b/TelegramSearchBot.LLM.Test/Service/AI/LLM/McpToolHelperTests.cs @@ -182,6 +182,7 @@ public static void ProcessThoughtAsync( } // --- Test Class --- + [Collection(McpToolHelperTestCollection.Name)] public class McpToolHelperTests { #pragma warning disable CS8618 // 单元测试中字段会在初始化方法中赋值 // 移除构造函数中的静态字段和 Mock 成员,改为在测试方法内部声明和设置 @@ -722,21 +723,22 @@ public void GetNativeToolDefinitions_IncludesExternalMcpTools() { }) }; - McpToolHelper.RegisterExternalTools( - externalTools, - async (server, tool, args) => { await Task.CompletedTask; return "mock result"; }); - - var tools = McpToolHelper.GetNativeToolDefinitions(); - - var externalTool = tools.FirstOrDefault(t => t.FunctionName == "mcp_test-server_testTool"); - Assert.NotNull(externalTool); - Assert.Contains("test-server", externalTool.FunctionDescription); - Assert.Contains("A test external tool", externalTool.FunctionDescription); - - // Clean up - McpToolHelper.RegisterExternalTools( - new List<(string, McpToolHelper.ExternalToolInfo)>(), - null); + try { + McpToolHelper.RegisterExternalTools( + externalTools, + async (server, tool, args) => { await Task.CompletedTask; return "mock result"; }); + + var tools = McpToolHelper.GetNativeToolDefinitions(); + + var externalTool = tools.FirstOrDefault(t => t.FunctionName == "mcp_test-server_testTool"); + Assert.NotNull(externalTool); + Assert.Contains("test-server", externalTool.FunctionDescription); + Assert.Contains("A test external tool", externalTool.FunctionDescription); + } finally { + McpToolHelper.RegisterExternalTools( + new List<(string, McpToolHelper.ExternalToolInfo)>(), + null); + } } [Fact] diff --git a/TelegramSearchBot.LLM.Test/Service/Tools/McpInstallerToolServiceTests.cs b/TelegramSearchBot.LLM.Test/Service/Tools/McpInstallerToolServiceTests.cs index cc860959..d2365da7 100644 --- a/TelegramSearchBot.LLM.Test/Service/Tools/McpInstallerToolServiceTests.cs +++ b/TelegramSearchBot.LLM.Test/Service/Tools/McpInstallerToolServiceTests.cs @@ -23,6 +23,7 @@ namespace TelegramSearchBot.Test.Service.Tools { /// /// Integration tests for McpInstallerToolService verifying the full add/list/remove/restart flow. /// + [Collection(McpToolHelperTestCollection.Name)] public class McpInstallerToolServiceTests : IDisposable { private readonly ServiceProvider _serviceProvider; private readonly McpServerManager _mcpServerManager; @@ -61,6 +62,9 @@ public McpInstallerToolServiceTests() { } public void Dispose() { + McpToolHelper.RegisterExternalTools( + new List<(string, McpToolHelper.ExternalToolInfo)>(), + null); _mcpServerManager.Dispose(); _serviceProvider.Dispose(); } diff --git a/TelegramSearchBot.Test/Service/Manage/ChatImportServiceTests.cs b/TelegramSearchBot.Test/Service/Manage/ChatImportServiceTests.cs new file mode 100644 index 00000000..f86fb030 --- /dev/null +++ b/TelegramSearchBot.Test/Service/Manage/ChatImportServiceTests.cs @@ -0,0 +1,147 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Moq; +using Telegram.Bot; +using TelegramSearchBot.Model; +using TelegramSearchBot.Service.Manage; +using Xunit; + +namespace TelegramSearchBot.Test.Service.Manage { + public class ChatImportServiceTests { + private readonly DbContextOptions _dbContextOptions; + + public ChatImportServiceTests() { + _dbContextOptions = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + } + + [Fact] + public async Task ImportFromFileAsync_TextOnlyExport_DoesNotRequireEmbeddedMediaFiles() { + var tempDir = Path.Combine(Path.GetTempPath(), $"chat-import-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDir); + + try { + var jsonPath = Path.Combine(tempDir, "result.json"); + await File.WriteAllTextAsync(jsonPath, """ + { + "name": "测试频道", + "type": "channel", + "id": 777, + "messages": [ + { + "id": 1, + "type": "service", + "date": "2025-08-09T07:33:01", + "date_unixtime": "1754695981", + "actor": "夸克网盘", + "actor_id": "channel2726412745", + "action": "create_channel", + "title": "夸克网盘", + "text": "", + "text_entities": [] + }, + { + "id": 2, + "type": "message", + "date": "2025-08-09T07:57:05", + "date_unixtime": "1754697425", + "from": "夸克网盘", + "from_id": "channel2726412745", + "photo": "(File not included. Change data exporting settings to download.)", + "photo_file_size": 257958, + "width": 905, + "height": 1280, + "text": [ + "链接:", + { + "type": "link", + "text": "https://pan.quark.cn/s/fa9a2f5f8757" + } + ], + "text_entities": [ + { + "type": "plain", + "text": "链接:" + }, + { + "type": "link", + "text": "https://pan.quark.cn/s/fa9a2f5f8757" + } + ] + }, + { + "id": 3, + "type": "message", + "date": "2025-08-09T08:00:00", + "date_unixtime": "1754697600", + "from": "Alice", + "from_id": "user123456", + "text": "plain string text", + "text_entities": [] + }, + { + "id": 4, + "type": "message", + "date": "2025-08-09T08:01:00", + "date_unixtime": "1754697660", + "from": "Bob", + "from_id": "user654321", + "photo": "(File not included. Change data exporting settings to download.)", + "text": "", + "text_entities": [], + "caption_entities": [ + { + "type": "bold", + "text": "加粗标题" + } + ] + } + ] + } + """); + + await using var context = new DataDbContext(_dbContextOptions); + var service = new ChatImportService( + new Mock>().Object, + new TestSendMessage(), + context); + + await service.ImportFromFileAsync(jsonPath); + + var imported = await context.Messages + .Where(m => m.GroupId == 777) + .OrderBy(m => m.MessageId) + .ToListAsync(); + + Assert.Equal(4, imported.Count); + Assert.Equal(2726412745L, imported[0].FromUserId); + Assert.Contains("create_channel", imported[0].Content); + Assert.Equal(2726412745L, imported[1].FromUserId); + Assert.Contains("https://pan.quark.cn/s/fa9a2f5f8757", imported[1].Content); + Assert.Equal("plain string text", imported[2].Content); + Assert.Equal(123456L, imported[2].FromUserId); + Assert.Contains("**加粗标题**", imported[3].Content); + Assert.Equal(654321L, imported[3].FromUserId); + } finally { + Directory.Delete(tempDir, true); + } + } + + private sealed class TestSendMessage : TelegramSearchBot.Manager.SendMessage { + public TestSendMessage() + : base( + new Mock().Object, + new Mock>().Object) { + } + + public override Task AddTask(Func action, bool isGroup) { + return Task.CompletedTask; + } + } + } +} diff --git a/TelegramSearchBot/Model/ChatExport/ChatExport.cs b/TelegramSearchBot/Model/ChatExport/ChatExport.cs index afbd9945..846e476c 100644 --- a/TelegramSearchBot/Model/ChatExport/ChatExport.cs +++ b/TelegramSearchBot/Model/ChatExport/ChatExport.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using Newtonsoft.Json; +using Newtonsoft.Json.Linq; namespace TelegramSearchBot.Model.ChatExport { public class ChatExport { @@ -8,6 +9,8 @@ public class ChatExport { public string Type { get; set; } public long Id { get; set; } public List Messages { get; set; } + [JsonProperty("messages_count")] + public int? MessagesCount { get; set; } } public class Message { @@ -16,7 +19,12 @@ public class Message { public DateTime Date { get; set; } public string Date_Unixtime { get; set; } public string From { get; set; } + [JsonProperty("from_id")] public string From_Id { get; set; } + [JsonProperty("actor_id")] + public string ActorId { get; set; } + public string Title { get; set; } + [JsonConverter(typeof(TextItemListConverter))] public List Text { get; set; } public List Text_Entities { get; set; } public string Edited { get; set; } @@ -40,6 +48,34 @@ public class Message { public string MimeType { get; set; } [JsonProperty("duration_seconds")] public int? DurationSeconds { get; set; } + public string Video { get; set; } + [JsonProperty("video_file_size")] + public int? VideoFileSize { get; set; } + public string Voice { get; set; } + [JsonProperty("voice_file_size")] + public int? VoiceFileSize { get; set; } + [JsonProperty("video_note")] + public string VideoNote { get; set; } + public StickerInfo Sticker { get; set; } + public string Location { get; set; } + public ContactInfo Contact { get; set; } + public PollInfo Poll { get; set; } + public string Game { get; set; } + public string Dice { get; set; } + [JsonProperty("forwarded_from")] + public string ForwardedFrom { get; set; } + [JsonProperty("saved_messages")] + public string SavedMessages { get; set; } + public string Actor { get; set; } + public string Action { get; set; } + [JsonProperty("action_type")] + public string ActionType { get; set; } + public List Reactions { get; set; } + [JsonProperty("via_bot")] + public string ViaBot { get; set; } + public string Caption { get; set; } + [JsonProperty("caption_entities")] + public List Caption_Entities { get; set; } } public class TextItem { @@ -57,4 +93,98 @@ public class TextEntity { [JsonProperty("document_id")] public string DocumentId { get; set; } } + + public class StickerInfo { + [JsonProperty("emoji")] + public string Emoji { get; set; } + [JsonProperty("sticker_set_id")] + public string StickerSetId { get; set; } + } + + public class ContactInfo { + [JsonProperty("phone_number")] + public string PhoneNumber { get; set; } + [JsonProperty("first_name")] + public string FirstName { get; set; } + [JsonProperty("last_name")] + public string LastName { get; set; } + } + + public class PollInfo { + public string Question { get; set; } + public List Options { get; set; } + [JsonProperty("total_voters")] + public int? TotalVoters { get; set; } + } + + public class PollOption { + public string Text { get; set; } + public int Voters { get; set; } + } + + public class ReactionInfo { + public string Type { get; set; } + public string Emoji { get; set; } + [JsonProperty("document_id")] + public string DocumentId { get; set; } + public int Count { get; set; } + } + + public class TextItemListConverter : JsonConverter { + public override bool CanWrite => false; + + public override bool CanConvert(Type objectType) { + return objectType == typeof(List); + } + + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) { + if (reader.TokenType == JsonToken.Null) { + return new List(); + } + + var token = JToken.Load(reader); + + if (token.Type == JTokenType.String) { + return new List { + new() { + Type = "plain", + Text = token.Value() ?? string.Empty + } + }; + } + + if (token.Type == JTokenType.Object) { + return new List { + DeserializeTextItem(token, serializer) + }; + } + + if (token.Type != JTokenType.Array) { + return new List(); + } + + var items = new List(); + foreach (var itemToken in token.Children()) { + items.Add(DeserializeTextItem(itemToken, serializer)); + } + + return items; + } + + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) { } + + private static TextItem DeserializeTextItem(JToken token, JsonSerializer serializer) { + if (token.Type == JTokenType.String) { + return new TextItem { + Type = "plain", + Text = token.Value() ?? string.Empty + }; + } + + return token.ToObject(serializer) ?? new TextItem { + Type = "plain", + Text = token.ToString(Formatting.None) + }; + } + } } diff --git a/TelegramSearchBot/Service/Manage/ChatImportService.cs b/TelegramSearchBot/Service/Manage/ChatImportService.cs index 5e0bb0bb..ddc1604a 100644 --- a/TelegramSearchBot/Service/Manage/ChatImportService.cs +++ b/TelegramSearchBot/Service/Manage/ChatImportService.cs @@ -1,6 +1,9 @@ using System; +using System.Collections.Generic; using System.IO; using System.Linq; +using System.Text; +using System.Text.RegularExpressions; using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; @@ -40,6 +43,7 @@ public async Task ImportFromFileAsync(string filePath) { try { var json = await File.ReadAllTextAsync(filePath); var chatExport = JsonConvert.DeserializeObject(json); + ArgumentNullException.ThrowIfNull(chatExport); await _send.Log($"开始导入聊天记录: {chatExport.Name}"); @@ -61,46 +65,46 @@ public async Task ImportFromFileAsync(string filePath) { dbMessage.GroupId = chatExport.Id; dbMessage.DateTime = message.Date; dbMessage.Content = GetMessageText(message); - dbMessage.FromUserId = long.TryParse(message.From_Id, out var fromId) ? fromId : 0; + dbMessage.FromUserId = ParseExportUserId(message.From_Id, message.ActorId); dbMessage.ReplyToMessageId = message.ReplyToMessageId ?? 0; // 获取json文件所在目录 var jsonDir = Path.GetDirectoryName(filePath); // 处理照片 - if (!string.IsNullOrEmpty(message.Photo)) { + if (HasEmbeddedFile(message.Photo)) { var photoPath = Path.Combine(Env.WorkDir, "Photos", $"{chatExport.Id}"); Directory.CreateDirectory(photoPath); var sourcePhotoPath = Path.Combine(jsonDir, message.Photo); var extension = Path.GetExtension(message.Photo) ?? ".jpg"; var photoFile = Path.Combine(photoPath, $"{message.Id}{extension}"); - if (!File.Exists(photoFile)) { + if (!File.Exists(sourcePhotoPath)) { + _logger.LogWarning($"图片不存在: {sourcePhotoPath}"); + } else if (!File.Exists(photoFile)) { File.Copy(sourcePhotoPath, photoFile, true); } } // 处理文件 - if (!string.IsNullOrEmpty(message.File)) { - if (!message.File.StartsWith("(File not included.")) { - var sourceFilePath = Path.Combine(jsonDir, message.File); - if (!File.Exists(sourceFilePath)) { - _logger.LogWarning($"文件不存在: {sourceFilePath}"); - } else { - var extension = Path.GetExtension(message.File) ?? ".dat"; - - // 根据文件类型保存到不同目录 - var destPath = IProcessVideo.IsVideo(message.File) - ? Path.Combine(Env.WorkDir, "Videos", $"{chatExport.Id}") - : IProcessAudio.IsAudio(message.File) - ? Path.Combine(Env.WorkDir, "Audios", $"{chatExport.Id}") - : Path.Combine(Env.WorkDir, "Files", $"{chatExport.Id}"); - - Directory.CreateDirectory(destPath); - var destFile = Path.Combine(destPath, $"{message.Id}{extension}"); - - if (!File.Exists(destFile)) { - File.Copy(sourceFilePath, destFile, true); - } + if (HasEmbeddedFile(message.File)) { + var sourceFilePath = Path.Combine(jsonDir, message.File); + if (!File.Exists(sourceFilePath)) { + _logger.LogWarning($"文件不存在: {sourceFilePath}"); + } else { + var extension = Path.GetExtension(message.File) ?? ".dat"; + + // 根据文件类型保存到不同目录 + var destPath = IProcessVideo.IsVideo(message.File) + ? Path.Combine(Env.WorkDir, "Videos", $"{chatExport.Id}") + : IProcessAudio.IsAudio(message.File) + ? Path.Combine(Env.WorkDir, "Audios", $"{chatExport.Id}") + : Path.Combine(Env.WorkDir, "Files", $"{chatExport.Id}"); + + Directory.CreateDirectory(destPath); + var destFile = Path.Combine(destPath, $"{message.Id}{extension}"); + + if (!File.Exists(destFile)) { + File.Copy(sourceFilePath, destFile, true); } } } @@ -117,60 +121,181 @@ public async Task ImportFromFileAsync(string filePath) { } private string GetMessageText(ExportMessage message) { - if (message.Text_Entities != null && message.Text_Entities.Count > 0) { - var result = new System.Text.StringBuilder(); - foreach (var entity in message.Text_Entities) { - switch (entity.Type) { - case "plain": - result.Append(entity.Text); - break; - case "bold": - result.Append($"**{entity.Text}**"); - break; - case "italic": - result.Append($"*{entity.Text}*"); - break; - case "strikethrough": - result.Append($"~~{entity.Text}~~"); - break; - case "spoiler": - result.Append($"||{entity.Text}||"); - break; - case "code": - result.Append($"`{entity.Text}`"); - break; - case "pre": - result.Append($"```{entity.Language ?? ""}\n{entity.Text}\n```"); - break; - case "text_link": - result.Append($"[{entity.Text}]({entity.Href})"); - break; - case "mention": - result.Append($"[{entity.Text}](tg://user?id={entity.Text.TrimStart('@')})"); - break; - case "bot_command": - result.Append($"/{entity.Text.TrimStart('/')}"); - break; - case "email": - result.Append($"[{entity.Text}](mailto:{entity.Text})"); - break; - case "hashtag": - result.Append($"{entity.Text}"); - break; - case "cashtag": - result.Append($"{entity.Text}"); - break; - case "custom_emoji": - result.Append($"[{entity.Text}](tg://emoji?id={entity.DocumentId})"); - break; - default: - result.Append(entity.Text); - break; - } + var result = new StringBuilder(); + + AppendFormattedText(result, message.Text_Entities, message.Text); + AppendSection(result, BuildCaptionText(message)); + + if (message.Sticker != null) { + AppendSection(result, $"[贴纸: {message.Sticker.Emoji ?? "?"}]"); + } + + if (message.Voice != null) { + AppendSection(result, "[语音消息]"); + } + + if (message.Video != null) { + AppendSection(result, "[视频]"); + } + + if (message.VideoNote != null) { + AppendSection(result, "[视频消息]"); + } + + if (!string.IsNullOrEmpty(message.Poll?.Question)) { + AppendSection(result, $"[投票: {message.Poll.Question}]"); + } + + if (message.Contact != null) { + var name = $"{message.Contact.FirstName} {message.Contact.LastName}".Trim(); + AppendSection(result, $"[联系人: {name}]"); + } + + if (message.Location != null) { + AppendSection(result, "[位置]"); + } + + if (!string.IsNullOrEmpty(message.Action)) { + var serviceParts = new List(); + if (!string.IsNullOrWhiteSpace(message.Actor)) { + serviceParts.Add(message.Actor); + } + + serviceParts.Add(message.Action); + + if (!string.IsNullOrWhiteSpace(message.Title) && + !string.Equals(message.Title, message.Actor, StringComparison.Ordinal)) { + serviceParts.Add(message.Title); + } + + AppendSection(result, $"[服务消息: {string.Join(" ", serviceParts)}]"); + } + + if (!string.IsNullOrWhiteSpace(message.ForwardedFrom)) { + AppendSection(result, $"[转发自: {message.ForwardedFrom}]"); + } + + if (!string.IsNullOrWhiteSpace(message.ViaBot)) { + AppendSection(result, $"[ViaBot: {message.ViaBot}]"); + } + + if (message.Reactions != null && message.Reactions.Count > 0) { + var reactions = string.Join(", ", message.Reactions + .Where(r => !string.IsNullOrWhiteSpace(r.Emoji)) + .Select(r => $"{r.Emoji}x{r.Count}")); + + if (!string.IsNullOrWhiteSpace(reactions)) { + AppendSection(result, $"[回应: {reactions}]"); } - return result.ToString(); } - return string.Empty; + + return result.ToString(); + } + + private static void AppendFormattedText(StringBuilder builder, List entities, List items) { + if (entities != null && entities.Count > 0) { + foreach (var entity in entities) { + builder.Append(FormatEntity(entity)); + } + + return; + } + + if (items == null || items.Count == 0) { + return; + } + + foreach (var item in items) { + builder.Append(FormatItem(item)); + } + } + + private static string BuildCaptionText(ExportMessage message) { + if (message.Caption_Entities != null && message.Caption_Entities.Count > 0) { + var builder = new StringBuilder(); + foreach (var entity in message.Caption_Entities) { + builder.Append(FormatEntity(entity)); + } + + return builder.ToString(); + } + + return message.Caption ?? string.Empty; + } + + private static string FormatEntity(TextEntity entity) { + if (string.IsNullOrEmpty(entity.Text)) { + return string.Empty; + } + + return entity.Type switch { + "plain" => entity.Text, + "bold" => $"**{entity.Text}**", + "italic" => $"*{entity.Text}*", + "strikethrough" => $"~~{entity.Text}~~", + "spoiler" => $"||{entity.Text}||", + "code" => $"`{entity.Text}`", + "pre" => $"```{entity.Language ?? ""}\n{entity.Text}\n```", + "text_link" => $"[{entity.Text}]({entity.Href})", + "mention" => $"[{entity.Text}](tg://user?id={entity.Text.TrimStart('@')})", + "bot_command" => $"/{entity.Text.TrimStart('/')}", + "email" => $"[{entity.Text}](mailto:{entity.Text})", + "custom_emoji" => $"[{entity.Text}](tg://emoji?id={entity.DocumentId})", + _ => entity.Text + }; + } + + private static string FormatItem(TextItem item) { + if (string.IsNullOrEmpty(item.Text)) { + return string.Empty; + } + + return item.Type switch { + "bold" => $"**{item.Text}**", + "italic" => $"*{item.Text}*", + "strikethrough" => $"~~{item.Text}~~", + "spoiler" => $"||{item.Text}||", + "code" => $"`{item.Text}`", + "pre" => $"```{item.Language ?? ""}\n{item.Text}\n```", + "link" or "text_link" when !string.IsNullOrWhiteSpace(item.Href) => $"[{item.Text}]({item.Href})", + _ => item.Text + }; + } + + private static void AppendSection(StringBuilder builder, string value) { + if (string.IsNullOrWhiteSpace(value)) { + return; + } + + if (builder.Length > 0) { + builder.AppendLine(); + } + + builder.Append(value); + } + + private static bool HasEmbeddedFile(string filePath) { + return !string.IsNullOrWhiteSpace(filePath) && + !filePath.StartsWith("(File not included.", StringComparison.Ordinal); + } + + private static long ParseExportUserId(params string[] rawIds) { + foreach (var rawId in rawIds) { + if (string.IsNullOrWhiteSpace(rawId)) { + continue; + } + + if (long.TryParse(rawId, out var numericId)) { + return numericId; + } + + var match = Regex.Match(rawId, @"-?\d+$"); + if (match.Success && long.TryParse(match.Value, out numericId)) { + return numericId; + } + } + + return 0; } public async Task ExecuteAsync(string command) {