From 9c45b750a1c747ce5bd36ce8f4d47ce5770c1906 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Mar 2026 08:33:22 +0000 Subject: [PATCH 1/7] Initial plan From fd02e1664feac0ab398610c00f43b1fe97595188 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Mar 2026 08:47:48 +0000 Subject: [PATCH 2/7] feat: embed telegram-bot-api as a managed child process with ClickOnce bundle support - Add EnableLocalBotAPI, TelegramBotApiId, TelegramBotApiHash, LocalBotApiPort to Config/Env - Auto-set BaseUrl and IsLocalAPI when EnableLocalBotAPI is true - Launch telegram-bot-api.exe as a ChildProcessManager-managed process in GeneralBootstrap - Add conditional Content entry for telegram-bot-api.exe in .csproj - Build telegram-bot-api from source via cmake+vcpkg in GitHub Actions push workflow Co-authored-by: ModerRAS <28183976+ModerRAS@users.noreply.github.com> --- .github/workflows/push.yml | 30 +++++++++++ TelegramSearchBot.Common/Env.cs | 21 +++++++- .../AppBootstrap/GeneralBootstrap.cs | 54 +++++++++++++++++++ TelegramSearchBot/TelegramSearchBot.csproj | 5 ++ 4 files changed, 108 insertions(+), 2 deletions(-) diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index 918c69dd..cf750ad3 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -25,6 +25,36 @@ jobs: uses: microsoft/setup-msbuild@v1.1 - name: Clear NuGet cache run: dotnet nuget locals all --clear + - name: Cache telegram-bot-api build + id: cache-tg-bot-api + uses: actions/cache@v4 + with: + path: telegram-bot-api-bin + key: tg-bot-api-win-x64-${{ hashFiles('.github/workflows/push.yml') }} + restore-keys: | + tg-bot-api-win-x64- + - name: Build telegram-bot-api from source + if: steps.cache-tg-bot-api.outputs.cache-hit != 'true' + shell: pwsh + run: | + git clone --recursive https://github.com/tdlib/telegram-bot-api.git + C:\vcpkg\vcpkg.exe install openssl:x64-windows-static zlib:x64-windows-static --no-print-usage + Push-Location telegram-bot-api + New-Item -ItemType Directory -Force build | Out-Null + Push-Location build + cmake -A x64 -DCMAKE_BUILD_TYPE=Release ` + -DCMAKE_TOOLCHAIN_FILE="C:/vcpkg/scripts/buildsystems/vcpkg.cmake" ` + -DVCPKG_TARGET_TRIPLET="x64-windows-static" ` + .. + cmake --build . --config Release --target telegram-bot-api + Pop-Location + Pop-Location + New-Item -ItemType Directory -Force telegram-bot-api-bin | Out-Null + Copy-Item "telegram-bot-api\build\Release\telegram-bot-api.exe" "telegram-bot-api-bin\telegram-bot-api.exe" + - name: Copy telegram-bot-api binary to project + shell: pwsh + run: | + Copy-Item "telegram-bot-api-bin\telegram-bot-api.exe" "TelegramSearchBot\telegram-bot-api.exe" - name: Restore dependencies run: dotnet restore --force --no-cache /p:BuildWithNetFrameworkHostedCompiler=true - name: Build diff --git a/TelegramSearchBot.Common/Env.cs b/TelegramSearchBot.Common/Env.cs index eaf50513..854a57a3 100644 --- a/TelegramSearchBot.Common/Env.cs +++ b/TelegramSearchBot.Common/Env.cs @@ -13,8 +13,17 @@ static Env() { } try { var config = JsonConvert.DeserializeObject(File.ReadAllText(Path.Combine(WorkDir, "Config.json"))); - BaseUrl = config.BaseUrl; - IsLocalAPI = config.IsLocalAPI; + EnableLocalBotAPI = config.EnableLocalBotAPI; + TelegramBotApiId = config.TelegramBotApiId; + TelegramBotApiHash = config.TelegramBotApiHash; + LocalBotApiPort = config.LocalBotApiPort; + if (config.EnableLocalBotAPI) { + BaseUrl = $"http://127.0.0.1:{config.LocalBotApiPort}"; + IsLocalAPI = true; + } else { + BaseUrl = config.BaseUrl; + IsLocalAPI = config.IsLocalAPI; + } BotToken = config.BotToken; AdminId = config.AdminId; EnableAutoOCR = config.EnableAutoOCR; @@ -44,6 +53,10 @@ static Env() { public static readonly long AdminId; public static readonly bool EnableAutoOCR; public static readonly bool EnableAutoASR; + public static readonly bool EnableLocalBotAPI; + public static readonly string TelegramBotApiId; + public static readonly string TelegramBotApiHash; + public static readonly int LocalBotApiPort; public static readonly string WorkDir; public static readonly int TaskDelayTimeout; public static readonly bool SameServer; @@ -70,6 +83,10 @@ public class Config { public bool EnableAutoASR { get; set; } = false; //public string WorkDir { get; set; } = "/data/TelegramSearchBot"; public bool IsLocalAPI { get; set; } = false; + public bool EnableLocalBotAPI { get; set; } = false; + public string TelegramBotApiId { get; set; } + public string TelegramBotApiHash { get; set; } + public int LocalBotApiPort { get; set; } = 8081; public bool SameServer { get; set; } = false; public int TaskDelayTimeout { get; set; } = 1000; public string OllamaModelName { get; set; } = "qwen2.5:72b-instruct-q2_K"; diff --git a/TelegramSearchBot/AppBootstrap/GeneralBootstrap.cs b/TelegramSearchBot/AppBootstrap/GeneralBootstrap.cs index 14503d52..2a000128 100644 --- a/TelegramSearchBot/AppBootstrap/GeneralBootstrap.cs +++ b/TelegramSearchBot/AppBootstrap/GeneralBootstrap.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.IO; using System.Linq; using System.Net; @@ -52,6 +53,23 @@ private static async Task WaitForGarnetReady(int port, int maxRetries = 20, int Log.Warning("等待 Garnet 服务就绪超时 (端口 {Port}),将继续启动(Redis 连接会自动重试)", port); } + /// + /// 等待本地 telegram-bot-api 服务端口就绪,最多等待 20 秒 + /// + private static async Task WaitForLocalBotApiReady(int port, int maxRetries = 40, int delayMs = 500) { + for (int i = 0; i < maxRetries; i++) { + try { + using var tcp = new System.Net.Sockets.TcpClient(); + await tcp.ConnectAsync("127.0.0.1", port); + Log.Information("telegram-bot-api 服务已就绪 (端口 {Port}),耗时约 {ElapsedMs}ms", port, i * delayMs); + return; + } catch { + await Task.Delay(delayMs); + } + } + Log.Warning("等待 telegram-bot-api 服务就绪超时 (端口 {Port}),将继续启动", port); + } + public static IHostBuilder CreateHostBuilder(string[] args) => Host.CreateDefaultBuilder(args) .UseSerilog() @@ -73,6 +91,42 @@ public static async Task Startup(string[] args) { // 等待 Garnet 服务就绪,避免竞态条件导致 Redis 连接失败 await WaitForGarnetReady(Env.SchedulerPort); + // 如果启用了本地 telegram-bot-api,则在此启动它 + if (Env.EnableLocalBotAPI) { + string botApiExePath = Path.Combine(AppContext.BaseDirectory, "telegram-bot-api.exe"); + if (File.Exists(botApiExePath)) { + if (string.IsNullOrEmpty(Env.TelegramBotApiId) || string.IsNullOrEmpty(Env.TelegramBotApiHash)) { + Log.Warning("EnableLocalBotAPI 为 true,但 TelegramBotApiId 或 TelegramBotApiHash 未配置,跳过本地 Bot API 启动"); + } else { + var botApiDataDir = Path.Combine(Env.WorkDir, "telegram-bot-api"); + Directory.CreateDirectory(botApiDataDir); + // 使用 ArgumentList 以正确处理路径中的空格 + // --local 模式允许大文件上传下载并将文件存储在本地 dir 下 + var startInfo = new ProcessStartInfo { + FileName = botApiExePath, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true + }; + startInfo.ArgumentList.Add("--local"); + startInfo.ArgumentList.Add($"--api-id={Env.TelegramBotApiId}"); + startInfo.ArgumentList.Add($"--api-hash={Env.TelegramBotApiHash}"); + startInfo.ArgumentList.Add($"--dir={botApiDataDir}"); + startInfo.ArgumentList.Add($"--http-port={Env.LocalBotApiPort}"); + var botApiProcess = Process.Start(startInfo); + if (botApiProcess == null) { + Log.Warning("telegram-bot-api 进程启动失败"); + } else { + childProcessManager.AddProcess(botApiProcess); + Log.Information("telegram-bot-api 已启动,等待端口 {Port} 就绪...", Env.LocalBotApiPort); + await WaitForLocalBotApiReady(Env.LocalBotApiPort); + } + } + } else { + Log.Warning("未找到 telegram-bot-api 可执行文件 {Path},跳过本地 Bot API 启动", botApiExePath); + } + } + IHost host = CreateHostBuilder(args) //.ConfigureLogging(logging => { // logging.ClearProviders(); diff --git a/TelegramSearchBot/TelegramSearchBot.csproj b/TelegramSearchBot/TelegramSearchBot.csproj index df2a4a73..7355a97a 100644 --- a/TelegramSearchBot/TelegramSearchBot.csproj +++ b/TelegramSearchBot/TelegramSearchBot.csproj @@ -96,6 +96,11 @@ + + + Always + + From aafd379eb09c45ba854c341cea930f8ca981c73a Mon Sep 17 00:00:00 2001 From: Test User Date: Thu, 19 Mar 2026 11:53:20 +0800 Subject: [PATCH 3/7] =?UTF-8?q?feat(import):=20=E5=A2=9E=E5=BC=BATelegram?= =?UTF-8?q?=20JSON=E5=AF=BC=E5=87=BA=E6=A0=BC=E5=BC=8F=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 扩展ChatExport模型支持更多消息类型 - 添加Video、Voice、VideoNote字段 - 添加Sticker、Contact、Poll、Location模型 - 添加ForwardedFrom、Reactions、ViaBot字段 - 添加Caption和Caption_Entities支持 - 添加服务消息(Actor、Action)支持 - 增强ChatImportService文本提取 - 支持简单的Text数组(不仅限Text_Entities) - 添加贴纸、语音、视频等媒体类型标记 - 添加投票、联系人、位置等消息类型处理 - 添加服务消息处理 Fixes #81 --- .../Model/ChatExport/ChatExport.cs | 67 +++++++++++++++++++ .../Service/Manage/ChatImportService.cs | 59 +++++++++++++++- 2 files changed, 123 insertions(+), 3 deletions(-) diff --git a/TelegramSearchBot/Model/ChatExport/ChatExport.cs b/TelegramSearchBot/Model/ChatExport/ChatExport.cs index afbd9945..2c579df5 100644 --- a/TelegramSearchBot/Model/ChatExport/ChatExport.cs +++ b/TelegramSearchBot/Model/ChatExport/ChatExport.cs @@ -8,6 +8,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,6 +18,7 @@ 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; } public List Text { get; set; } public List Text_Entities { get; set; } @@ -40,6 +43,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 +88,40 @@ 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; } + } } diff --git a/TelegramSearchBot/Service/Manage/ChatImportService.cs b/TelegramSearchBot/Service/Manage/ChatImportService.cs index 5e0bb0bb..0348f5d5 100644 --- a/TelegramSearchBot/Service/Manage/ChatImportService.cs +++ b/TelegramSearchBot/Service/Manage/ChatImportService.cs @@ -117,8 +117,9 @@ public async Task ImportFromFileAsync(string filePath) { } private string GetMessageText(ExportMessage message) { + var result = new System.Text.StringBuilder(); + 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": @@ -168,9 +169,61 @@ private string GetMessageText(ExportMessage message) { break; } } - return result.ToString(); + } else if (message.Text != null && message.Text.Count > 0) { + foreach (var item in message.Text) { + if (!string.IsNullOrEmpty(item.Text)) { + result.Append(item.Text); + } + } + } + + if (!string.IsNullOrEmpty(message.Caption)) { + if (result.Length > 0) result.AppendLine(); + result.Append(message.Caption); + } + + if (message.Sticker != null) { + if (result.Length > 0) result.AppendLine(); + result.Append($"[贴纸: {message.Sticker.Emoji ?? "?"}]"); + } + + if (message.Voice != null) { + if (result.Length > 0) result.AppendLine(); + result.Append("[语音消息]"); + } + + if (message.Video != null) { + if (result.Length > 0) result.AppendLine(); + result.Append("[视频]"); } - return string.Empty; + + if (message.VideoNote != null) { + if (result.Length > 0) result.AppendLine(); + result.Append("[视频消息]"); + } + + if (!string.IsNullOrEmpty(message.Poll?.Question)) { + if (result.Length > 0) result.AppendLine(); + result.Append($"[投票: {message.Poll.Question}]"); + } + + if (message.Contact != null) { + if (result.Length > 0) result.AppendLine(); + var name = $"{message.Contact.FirstName} {message.Contact.LastName}".Trim(); + result.Append($"[联系人: {name}]"); + } + + if (message.Location != null) { + if (result.Length > 0) result.AppendLine(); + result.Append("[位置]"); + } + + if (!string.IsNullOrEmpty(message.Action)) { + if (result.Length > 0) result.AppendLine(); + result.Append($"[服务消息: {message.Action}]"); + } + + return result.ToString(); } public async Task ExecuteAsync(string command) { From 10ab143167fe99bdc9f14032657de3f77e27c251 Mon Sep 17 00:00:00 2001 From: Test User Date: Thu, 19 Mar 2026 12:19:22 +0800 Subject: [PATCH 4/7] chore: fix code formatting with dotnet format Fix whitespace formatting issues detected by CI on Windows --- .../Attributes/McpAttributes.cs | 3 +- .../Tools/IterationLimitReachedPayload.cs | 2 +- ...60303031828_AddUserWithGroupUniqueIndex.cs | 14 ++---- ...0313124507_AddChannelWithModelIsDeleted.cs | 14 ++---- .../Service/AI/LLM/GeneralLLMServiceTests.cs | 29 +++++++++--- .../AI/LLM/ModelCapabilityServiceTests.cs | 46 +++++++++++++------ ...OpenAIProviderHistorySerializationTests.cs | 2 +- .../Service/AI/LLM/GeminiService.cs | 2 +- .../Service/AI/LLM/McpToolHelper.cs | 9 ++-- .../Service/AI/LLM/OllamaService.cs | 2 +- .../Service/AI/LLM/OpenAIService.cs | 10 ++-- .../Service/Mcp/McpClient.cs | 2 +- .../Service/Mcp/McpServerManager.cs | 4 +- .../Service/Tools/FileToolService.cs | 6 +-- .../Helper/WordCloudHelperTests.cs | 4 +- .../Extension/ServiceCollectionExtension.cs | 2 +- .../BotAPI/SendMessageService.Streaming.cs | 2 +- .../Service/Storage/MessageService.cs | 10 ++-- .../Service/Vector/FaissVectorService.cs | 6 +-- 19 files changed, 97 insertions(+), 72 deletions(-) diff --git a/TelegramSearchBot.Common/Attributes/McpAttributes.cs b/TelegramSearchBot.Common/Attributes/McpAttributes.cs index 519b0001..9a383c34 100644 --- a/TelegramSearchBot.Common/Attributes/McpAttributes.cs +++ b/TelegramSearchBot.Common/Attributes/McpAttributes.cs @@ -1,7 +1,6 @@ using System; -namespace TelegramSearchBot.Attributes -{ +namespace TelegramSearchBot.Attributes { /// /// Marks a method as a tool that can be called by the LLM. /// Deprecated: Use instead for built-in tools. diff --git a/TelegramSearchBot.Common/Model/Tools/IterationLimitReachedPayload.cs b/TelegramSearchBot.Common/Model/Tools/IterationLimitReachedPayload.cs index b605ddef..4dec7584 100644 --- a/TelegramSearchBot.Common/Model/Tools/IterationLimitReachedPayload.cs +++ b/TelegramSearchBot.Common/Model/Tools/IterationLimitReachedPayload.cs @@ -22,7 +22,7 @@ public static bool IsIterationLimitMessage(string content) { /// 在累积内容末尾追加标记 /// public static string AppendMarker(string accumulatedContent) { - return (accumulatedContent ?? string.Empty) + Marker; + return ( accumulatedContent ?? string.Empty ) + Marker; } /// diff --git a/TelegramSearchBot.Database/Migrations/20260303031828_AddUserWithGroupUniqueIndex.cs b/TelegramSearchBot.Database/Migrations/20260303031828_AddUserWithGroupUniqueIndex.cs index 3378f31b..79ea689b 100644 --- a/TelegramSearchBot.Database/Migrations/20260303031828_AddUserWithGroupUniqueIndex.cs +++ b/TelegramSearchBot.Database/Migrations/20260303031828_AddUserWithGroupUniqueIndex.cs @@ -1,15 +1,12 @@ -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; #nullable disable -namespace TelegramSearchBot.Migrations -{ +namespace TelegramSearchBot.Migrations { /// - public partial class AddUserWithGroupUniqueIndex : Migration - { + public partial class AddUserWithGroupUniqueIndex : Migration { /// - protected override void Up(MigrationBuilder migrationBuilder) - { + protected override void Up(MigrationBuilder migrationBuilder) { migrationBuilder.CreateIndex( name: "IX_UsersWithGroup_UserId_GroupId", table: "UsersWithGroup", @@ -18,8 +15,7 @@ protected override void Up(MigrationBuilder migrationBuilder) } /// - protected override void Down(MigrationBuilder migrationBuilder) - { + protected override void Down(MigrationBuilder migrationBuilder) { migrationBuilder.DropIndex( name: "IX_UsersWithGroup_UserId_GroupId", table: "UsersWithGroup"); diff --git a/TelegramSearchBot.Database/Migrations/20260313124507_AddChannelWithModelIsDeleted.cs b/TelegramSearchBot.Database/Migrations/20260313124507_AddChannelWithModelIsDeleted.cs index 045e395e..ccd5f76c 100644 --- a/TelegramSearchBot.Database/Migrations/20260313124507_AddChannelWithModelIsDeleted.cs +++ b/TelegramSearchBot.Database/Migrations/20260313124507_AddChannelWithModelIsDeleted.cs @@ -1,15 +1,12 @@ -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; #nullable disable -namespace TelegramSearchBot.Migrations -{ +namespace TelegramSearchBot.Migrations { /// - public partial class AddChannelWithModelIsDeleted : Migration - { + public partial class AddChannelWithModelIsDeleted : Migration { /// - protected override void Up(MigrationBuilder migrationBuilder) - { + protected override void Up(MigrationBuilder migrationBuilder) { migrationBuilder.AddColumn( name: "IsDeleted", table: "ChannelsWithModel", @@ -19,8 +16,7 @@ protected override void Up(MigrationBuilder migrationBuilder) } /// - protected override void Down(MigrationBuilder migrationBuilder) - { + protected override void Down(MigrationBuilder migrationBuilder) { migrationBuilder.DropColumn( name: "IsDeleted", table: "ChannelsWithModel"); diff --git a/TelegramSearchBot.LLM.Test/Service/AI/LLM/GeneralLLMServiceTests.cs b/TelegramSearchBot.LLM.Test/Service/AI/LLM/GeneralLLMServiceTests.cs index 52ec785b..87e3e109 100644 --- a/TelegramSearchBot.LLM.Test/Service/AI/LLM/GeneralLLMServiceTests.cs +++ b/TelegramSearchBot.LLM.Test/Service/AI/LLM/GeneralLLMServiceTests.cs @@ -102,12 +102,20 @@ public async Task GetChannelsAsync_NoModels_ReturnsEmpty() { public async Task GetChannelsAsync_WithModel_ReturnsOrderedChannels() { // Arrange var channel1 = new LLMChannel { - Name = "ch1", Gateway = "gw1", ApiKey = "key1", - Provider = LLMProvider.OpenAI, Parallel = 2, Priority = 1 + Name = "ch1", + Gateway = "gw1", + ApiKey = "key1", + Provider = LLMProvider.OpenAI, + Parallel = 2, + Priority = 1 }; var channel2 = new LLMChannel { - Name = "ch2", Gateway = "gw2", ApiKey = "key2", - Provider = LLMProvider.OpenAI, Parallel = 3, Priority = 10 + Name = "ch2", + Gateway = "gw2", + ApiKey = "key2", + Provider = LLMProvider.OpenAI, + Parallel = 3, + Priority = 10 }; _dbContext.LLMChannels.AddRange(channel1, channel2); await _dbContext.SaveChangesAsync(); @@ -130,7 +138,10 @@ public async Task GetChannelsAsync_WithModel_ReturnsOrderedChannels() { public async Task ExecAsync_NoModelConfigured_YieldsNoResults() { // Arrange - no group settings configured var message = new TelegramSearchBot.Model.Data.Message { - Content = "test", GroupId = 123, MessageId = 1, FromUserId = 1 + Content = "test", + GroupId = 123, + MessageId = 1, + FromUserId = 1 }; // Act @@ -153,8 +164,12 @@ public async Task GetAvailableCapacityAsync_NoChannels_ReturnsZero() { public async Task GetAvailableCapacityAsync_WithChannels_ReturnsCapacity() { // Arrange var channel = new LLMChannel { - Name = "ch1", Gateway = "gw1", ApiKey = "key1", - Provider = LLMProvider.OpenAI, Parallel = 5, Priority = 1 + Name = "ch1", + Gateway = "gw1", + ApiKey = "key1", + Provider = LLMProvider.OpenAI, + Parallel = 5, + Priority = 1 }; _dbContext.LLMChannels.Add(channel); await _dbContext.SaveChangesAsync(); diff --git a/TelegramSearchBot.LLM.Test/Service/AI/LLM/ModelCapabilityServiceTests.cs b/TelegramSearchBot.LLM.Test/Service/AI/LLM/ModelCapabilityServiceTests.cs index a649e8db..da4ef579 100644 --- a/TelegramSearchBot.LLM.Test/Service/AI/LLM/ModelCapabilityServiceTests.cs +++ b/TelegramSearchBot.LLM.Test/Service/AI/LLM/ModelCapabilityServiceTests.cs @@ -67,8 +67,12 @@ public async Task GetModelCapabilities_NotFound_ReturnsNull() { public async Task GetModelCapabilities_WithCapabilities_ReturnsCorrectModel() { // Arrange var channel = new LLMChannel { - Name = "test", Gateway = "gw", ApiKey = "key", - Provider = LLMProvider.OpenAI, Parallel = 1, Priority = 1 + Name = "test", + Gateway = "gw", + ApiKey = "key", + Provider = LLMProvider.OpenAI, + Parallel = 1, + Priority = 1 }; _dbContext.LLMChannels.Add(channel); await _dbContext.SaveChangesAsync(); @@ -106,8 +110,12 @@ public async Task GetModelCapabilities_WithCapabilities_ReturnsCorrectModel() { public async Task GetToolCallingSupportedModels_ReturnsCorrectModels() { // Arrange var channel = new LLMChannel { - Name = "test", Gateway = "gw", ApiKey = "key", - Provider = LLMProvider.OpenAI, Parallel = 1, Priority = 1 + Name = "test", + Gateway = "gw", + ApiKey = "key", + Provider = LLMProvider.OpenAI, + Parallel = 1, + Priority = 1 }; _dbContext.LLMChannels.Add(channel); await _dbContext.SaveChangesAsync(); @@ -138,7 +146,7 @@ public async Task GetToolCallingSupportedModels_ReturnsCorrectModels() { await _dbContext.SaveChangesAsync(); // Act - var result = (await _service.GetToolCallingSupportedModels()).ToList(); + var result = ( await _service.GetToolCallingSupportedModels() ).ToList(); // Assert Assert.Single(result); @@ -149,8 +157,12 @@ public async Task GetToolCallingSupportedModels_ReturnsCorrectModels() { public async Task GetVisionSupportedModels_ReturnsCorrectModels() { // Arrange var channel = new LLMChannel { - Name = "test", Gateway = "gw", ApiKey = "key", - Provider = LLMProvider.OpenAI, Parallel = 1, Priority = 1 + Name = "test", + Gateway = "gw", + ApiKey = "key", + Provider = LLMProvider.OpenAI, + Parallel = 1, + Priority = 1 }; _dbContext.LLMChannels.Add(channel); await _dbContext.SaveChangesAsync(); @@ -170,7 +182,7 @@ public async Task GetVisionSupportedModels_ReturnsCorrectModels() { await _dbContext.SaveChangesAsync(); // Act - var result = (await _service.GetVisionSupportedModels()).ToList(); + var result = ( await _service.GetVisionSupportedModels() ).ToList(); // Assert Assert.Single(result); @@ -181,8 +193,12 @@ public async Task GetVisionSupportedModels_ReturnsCorrectModels() { public async Task GetEmbeddingModels_ReturnsCorrectModels() { // Arrange var channel = new LLMChannel { - Name = "test", Gateway = "gw", ApiKey = "key", - Provider = LLMProvider.OpenAI, Parallel = 1, Priority = 1 + Name = "test", + Gateway = "gw", + ApiKey = "key", + Provider = LLMProvider.OpenAI, + Parallel = 1, + Priority = 1 }; _dbContext.LLMChannels.Add(channel); await _dbContext.SaveChangesAsync(); @@ -202,7 +218,7 @@ public async Task GetEmbeddingModels_ReturnsCorrectModels() { await _dbContext.SaveChangesAsync(); // Act - var result = (await _service.GetEmbeddingModels()).ToList(); + var result = ( await _service.GetEmbeddingModels() ).ToList(); // Assert Assert.Single(result); @@ -213,8 +229,12 @@ public async Task GetEmbeddingModels_ReturnsCorrectModels() { public async Task CleanupOldCapabilities_RemovesOldEntries() { // Arrange var channel = new LLMChannel { - Name = "test", Gateway = "gw", ApiKey = "key", - Provider = LLMProvider.OpenAI, Parallel = 1, Priority = 1 + Name = "test", + Gateway = "gw", + ApiKey = "key", + Provider = LLMProvider.OpenAI, + Parallel = 1, + Priority = 1 }; _dbContext.LLMChannels.Add(channel); await _dbContext.SaveChangesAsync(); diff --git a/TelegramSearchBot.LLM.Test/Service/AI/LLM/OpenAIProviderHistorySerializationTests.cs b/TelegramSearchBot.LLM.Test/Service/AI/LLM/OpenAIProviderHistorySerializationTests.cs index 3d5b003c..0529dbb2 100644 --- a/TelegramSearchBot.LLM.Test/Service/AI/LLM/OpenAIProviderHistorySerializationTests.cs +++ b/TelegramSearchBot.LLM.Test/Service/AI/LLM/OpenAIProviderHistorySerializationTests.cs @@ -79,7 +79,7 @@ public void SerializeProviderHistory_WithToolCallHistory_PreservesContent() { }; var serialized = OpenAIService.SerializeProviderHistory(history); - + Assert.Equal(5, serialized.Count); Assert.Contains("tool_call", serialized[2].Content); Assert.Contains("bash", serialized[3].Content); diff --git a/TelegramSearchBot.LLM/Service/AI/LLM/GeminiService.cs b/TelegramSearchBot.LLM/Service/AI/LLM/GeminiService.cs index b6c62aaf..2eecde60 100644 --- a/TelegramSearchBot.LLM/Service/AI/LLM/GeminiService.cs +++ b/TelegramSearchBot.LLM/Service/AI/LLM/GeminiService.cs @@ -341,7 +341,7 @@ public async IAsyncEnumerable ExecAsync( executionContext.IterationLimitReached = true; executionContext.SnapshotData = new LlmContinuationSnapshot { ChatId = ChatId, - OriginalMessageId = (int)message.MessageId, + OriginalMessageId = ( int ) message.MessageId, UserId = message.FromUserId, ModelName = modelName, Provider = "Gemini", diff --git a/TelegramSearchBot.LLM/Service/AI/LLM/McpToolHelper.cs b/TelegramSearchBot.LLM/Service/AI/LLM/McpToolHelper.cs index e4595ad2..be5862d2 100644 --- a/TelegramSearchBot.LLM/Service/AI/LLM/McpToolHelper.cs +++ b/TelegramSearchBot.LLM/Service/AI/LLM/McpToolHelper.cs @@ -170,7 +170,7 @@ private static string RegisterToolsAndGetPromptString(List assemblies) var builtInParamAttr = param.GetCustomAttribute(); var mcpParamAttr = param.GetCustomAttribute(); var paramDescription = builtInParamAttr?.Description ?? mcpParamAttr?.Description ?? $"Parameter '{param.Name}'"; - var paramIsRequired = builtInParamAttr?.IsRequired ?? mcpParamAttr?.IsRequired ?? (!param.IsOptional && !param.HasDefaultValue); + var paramIsRequired = builtInParamAttr?.IsRequired ?? mcpParamAttr?.IsRequired ?? ( !param.IsOptional && !param.HasDefaultValue ); var paramType = MapToJsonSchemaType(param.ParameterType); properties[param.Name] = new Dictionary { @@ -511,7 +511,7 @@ private static (string toolName, Dictionary arguments) ParseTool } } - if (toolName == null || (!ToolRegistry.ContainsKey(toolName) && !ExternalToolRegistry.ContainsKey(toolName))) { + if (toolName == null || ( !ToolRegistry.ContainsKey(toolName) && !ExternalToolRegistry.ContainsKey(toolName) )) { _sLogger?.LogWarning($"ParseToolElement: Unregistered tool '{element.Name.LocalName}'"); return (null, null); } @@ -703,8 +703,7 @@ public static async Task ExecuteRegisteredToolAsync(string toolName, Dic if (result is Task taskResult) { await taskResult; - if (taskResult.GetType().IsGenericType) - { + if (taskResult.GetType().IsGenericType) { return ( ( dynamic ) taskResult ).Result; } return null; @@ -900,7 +899,7 @@ public static void RegisterExternalMcpTools(Interface.Mcp.IMcpServerManager mcpS RegisterExternalTools( toolInfos, async (serverName, toolName, arguments) => { - var objectArgs = arguments.ToDictionary(kvp => kvp.Key, kvp => (object)kvp.Value); + var objectArgs = arguments.ToDictionary(kvp => kvp.Key, kvp => ( object ) kvp.Value); var result = await mcpServerManager.CallToolAsync(serverName, toolName, objectArgs); if (result.IsError) { return $"Error: {string.Join("\n", result.Content?.Select(c => c.Text ?? "") ?? Enumerable.Empty())}"; diff --git a/TelegramSearchBot.LLM/Service/AI/LLM/OllamaService.cs b/TelegramSearchBot.LLM/Service/AI/LLM/OllamaService.cs index e8a810e1..72d25c12 100644 --- a/TelegramSearchBot.LLM/Service/AI/LLM/OllamaService.cs +++ b/TelegramSearchBot.LLM/Service/AI/LLM/OllamaService.cs @@ -202,7 +202,7 @@ public async IAsyncEnumerable ExecAsync(Model.Data.Message message, long executionContext.IterationLimitReached = true; executionContext.SnapshotData = new LlmContinuationSnapshot { ChatId = ChatId, - OriginalMessageId = (int)message.MessageId, + OriginalMessageId = ( int ) message.MessageId, UserId = message.FromUserId, ModelName = modelName, Provider = "Ollama", diff --git a/TelegramSearchBot.LLM/Service/AI/LLM/OpenAIService.cs b/TelegramSearchBot.LLM/Service/AI/LLM/OpenAIService.cs index cf90a0eb..fb9a1a37 100644 --- a/TelegramSearchBot.LLM/Service/AI/LLM/OpenAIService.cs +++ b/TelegramSearchBot.LLM/Service/AI/LLM/OpenAIService.cs @@ -719,10 +719,10 @@ private static bool IsToolCallingNotSupportedError(Exception ex) { } } // Common error patterns when a model/API doesn't support tool calling - return message.Contains("tools", StringComparison.OrdinalIgnoreCase) && - (message.Contains("not supported", StringComparison.OrdinalIgnoreCase) || + return message.Contains("tools", StringComparison.OrdinalIgnoreCase) && + ( message.Contains("not supported", StringComparison.OrdinalIgnoreCase) || message.Contains("unsupported", StringComparison.OrdinalIgnoreCase) || - message.Contains("invalid", StringComparison.OrdinalIgnoreCase)) || + message.Contains("invalid", StringComparison.OrdinalIgnoreCase) ) || message.Contains("unrecognized request argument", StringComparison.OrdinalIgnoreCase); } @@ -860,7 +860,7 @@ private async IAsyncEnumerable ExecWithNativeToolCallingAsync( executionContext.IterationLimitReached = true; executionContext.SnapshotData = new LlmContinuationSnapshot { ChatId = ChatId, - OriginalMessageId = (int)message.MessageId, + OriginalMessageId = ( int ) message.MessageId, UserId = message.FromUserId, ModelName = modelName, Provider = "OpenAI", @@ -967,7 +967,7 @@ private async IAsyncEnumerable ExecWithXmlToolCallingAsync( executionContext.IterationLimitReached = true; executionContext.SnapshotData = new LlmContinuationSnapshot { ChatId = ChatId, - OriginalMessageId = (int)message.MessageId, + OriginalMessageId = ( int ) message.MessageId, UserId = message.FromUserId, ModelName = modelName, Provider = "OpenAI", diff --git a/TelegramSearchBot.LLM/Service/Mcp/McpClient.cs b/TelegramSearchBot.LLM/Service/Mcp/McpClient.cs index f36c45b5..8cf2a9e9 100644 --- a/TelegramSearchBot.LLM/Service/Mcp/McpClient.cs +++ b/TelegramSearchBot.LLM/Service/Mcp/McpClient.cs @@ -179,7 +179,7 @@ private async Task CleanupProcessAsync() { _process.Kill(true); // Wait briefly for the process to actually exit try { - _process.WaitForExit(ProcessExitTimeoutMs); + _process.WaitForExit(ProcessExitTimeoutMs); } catch { } } } catch { } diff --git a/TelegramSearchBot.LLM/Service/Mcp/McpServerManager.cs b/TelegramSearchBot.LLM/Service/Mcp/McpServerManager.cs index d93a8a05..fa4f2cdb 100644 --- a/TelegramSearchBot.LLM/Service/Mcp/McpServerManager.cs +++ b/TelegramSearchBot.LLM/Service/Mcp/McpServerManager.cs @@ -152,7 +152,7 @@ private async Task DisconnectServerAsync(string serverName) { if (_clients.TryRemove(serverName, out var client)) { try { await client.DisconnectAsync(); - (client as IDisposable)?.Dispose(); + ( client as IDisposable )?.Dispose(); } catch (Exception ex) { _logger.LogWarning(ex, "Error disconnecting MCP server '{Name}'", serverName); } @@ -293,7 +293,7 @@ public async Task ShutdownAllAsync() { public void Dispose() { foreach (var kvp in _clients) { try { - (kvp.Value as IDisposable)?.Dispose(); + ( kvp.Value as IDisposable )?.Dispose(); } catch { } } _clients.Clear(); diff --git a/TelegramSearchBot.LLM/Service/Tools/FileToolService.cs b/TelegramSearchBot.LLM/Service/Tools/FileToolService.cs index 4c9a517d..8479e9ff 100644 --- a/TelegramSearchBot.LLM/Service/Tools/FileToolService.cs +++ b/TelegramSearchBot.LLM/Service/Tools/FileToolService.cs @@ -286,7 +286,7 @@ private static string ResolvePath(string path) { private static int CountOccurrences(string text, string pattern) { int count = 0; int index = 0; - while ((index = text.IndexOf(pattern, index, StringComparison.Ordinal)) != -1) { + while (( index = text.IndexOf(pattern, index, StringComparison.Ordinal) ) != -1) { count++; index += pattern.Length; } @@ -296,8 +296,8 @@ private static int CountOccurrences(string text, string pattern) { private static string FormatFileSize(long bytes) { if (bytes < 1024) return $"{bytes}B"; if (bytes < 1024 * 1024) return $"{bytes / 1024.0:F1}KB"; - if (bytes < 1024 * 1024 * 1024) return $"{bytes / (1024.0 * 1024):F1}MB"; - return $"{bytes / (1024.0 * 1024 * 1024):F1}GB"; + if (bytes < 1024 * 1024 * 1024) return $"{bytes / ( 1024.0 * 1024 ):F1}MB"; + return $"{bytes / ( 1024.0 * 1024 * 1024 ):F1}GB"; } } } diff --git a/TelegramSearchBot.Test/Helper/WordCloudHelperTests.cs b/TelegramSearchBot.Test/Helper/WordCloudHelperTests.cs index bbfd566e..a4eac7ac 100644 --- a/TelegramSearchBot.Test/Helper/WordCloudHelperTests.cs +++ b/TelegramSearchBot.Test/Helper/WordCloudHelperTests.cs @@ -33,8 +33,8 @@ public void GenerateWordCloud_ShouldCreateImageFile() { // 打印文件路径 System.Console.WriteLine($"词云图片已保存到: {Path.GetFullPath(outputPath)}"); } catch (Exception ex) when (ex is System.DllNotFoundException || - (ex is System.TypeInitializationException tex && - tex.InnerException is System.PlatformNotSupportedException or System.DllNotFoundException)) { + ( ex is System.TypeInitializationException tex && + tex.InnerException is System.PlatformNotSupportedException or System.DllNotFoundException )) { // 在Linux上GDI+不可用时跳过测试(包括libgdiplus未安装或平台不支持) System.Console.WriteLine($"跳过测试:{ex.Message}"); return; diff --git a/TelegramSearchBot/Extension/ServiceCollectionExtension.cs b/TelegramSearchBot/Extension/ServiceCollectionExtension.cs index 409dedbd..f7135663 100644 --- a/TelegramSearchBot/Extension/ServiceCollectionExtension.cs +++ b/TelegramSearchBot/Extension/ServiceCollectionExtension.cs @@ -22,11 +22,11 @@ using TelegramSearchBot.Executor; using TelegramSearchBot.Helper; using TelegramSearchBot.Interface; +using TelegramSearchBot.Interface.AI.LLM; using TelegramSearchBot.Interface.Controller; using TelegramSearchBot.Manager; using TelegramSearchBot.Model; using TelegramSearchBot.Search.Tool; -using TelegramSearchBot.Interface.AI.LLM; using TelegramSearchBot.Service.BotAPI; using TelegramSearchBot.Service.Storage; using TelegramSearchBot.View; diff --git a/TelegramSearchBot/Service/BotAPI/SendMessageService.Streaming.cs b/TelegramSearchBot/Service/BotAPI/SendMessageService.Streaming.cs index 605949f1..f32db4df 100644 --- a/TelegramSearchBot/Service/BotAPI/SendMessageService.Streaming.cs +++ b/TelegramSearchBot/Service/BotAPI/SendMessageService.Streaming.cs @@ -359,7 +359,7 @@ await botClient.SendMessage( } // Generate a unique draftId to avoid collisions for concurrent requests to the same message - int draftId = unchecked((int)(chatId ^ replyTo ^ DateTime.UtcNow.Ticks)); + int draftId = unchecked(( int ) ( chatId ^ replyTo ^ DateTime.UtcNow.Ticks )); string latestContent = null; bool draftStarted = false; diff --git a/TelegramSearchBot/Service/Storage/MessageService.cs b/TelegramSearchBot/Service/Storage/MessageService.cs index 39051747..2fc829a9 100644 --- a/TelegramSearchBot/Service/Storage/MessageService.cs +++ b/TelegramSearchBot/Service/Storage/MessageService.cs @@ -54,7 +54,7 @@ public async Task AddToSqlite(MessageOption messageOption) { var existingUserInGroup = await DataContext.UsersWithGroup .FirstOrDefaultAsync(s => s.UserId == messageOption.UserId && s.GroupId == messageOption.ChatId); - + if (existingUserInGroup == null) { // 使用 try-catch 处理并发插入导致的唯一约束冲突 try { @@ -75,7 +75,7 @@ await DataContext.UsersWithGroup.AddAsync(new UserWithGroup() { var existingUserData = await DataContext.UserData .FirstOrDefaultAsync(s => s.Id == messageOption.User.Id); - + if (existingUserData == null && messageOption.User != null) { try { await DataContext.UserData.AddAsync(new UserData() { @@ -96,7 +96,7 @@ await DataContext.UserData.AddAsync(new UserData() { var existingGroupData = await DataContext.GroupData .FirstOrDefaultAsync(s => s.Id == messageOption.Chat.Id); - + if (existingGroupData == null && messageOption.Chat != null) { try { await DataContext.GroupData.AddAsync(new GroupData() { @@ -112,7 +112,7 @@ await DataContext.GroupData.AddAsync(new GroupData() { DataContext.ChangeTracker.Clear(); } } - + var message = new Message() { GroupId = messageOption.ChatId, MessageId = messageOption.MessageId, @@ -123,7 +123,7 @@ await DataContext.GroupData.AddAsync(new GroupData() { if (messageOption.ReplyTo != 0) { message.ReplyToMessageId = messageOption.ReplyTo; } - + await DataContext.Messages.AddAsync(message); await DataContext.SaveChangesAsync(); return message.Id; diff --git a/TelegramSearchBot/Service/Vector/FaissVectorService.cs b/TelegramSearchBot/Service/Vector/FaissVectorService.cs index 24512ab0..035199af 100644 --- a/TelegramSearchBot/Service/Vector/FaissVectorService.cs +++ b/TelegramSearchBot/Service/Vector/FaissVectorService.cs @@ -75,7 +75,7 @@ private async Task CheckEmbeddingModelAvailabilityAsync() { try { // 尝试生成一个简单的测试向量 var testVector = await _generalLLMService.GenerateEmbeddingsAsync("test", CancellationToken.None); - + if (testVector == null || testVector.Length == 0) { _logger.LogWarning("嵌入模型不可用或返回空向量,已禁用FAISS向量服务"); _isEnabled = false; @@ -680,14 +680,14 @@ private static void SetSegmentVectorizedStatus(DataDbContext dbContext, Conversa public async Task GenerateVectorAsync(string text) { try { var vector = await _generalLLMService.GenerateEmbeddingsAsync(text); - + // 如果返回空向量,记录警告并禁用服务 if (vector == null || vector.Length == 0) { _logger.LogWarning("嵌入模型返回空向量,FAISS向量服务已禁用"); _isEnabled = false; return Array.Empty(); } - + return vector; } catch (Exception ex) { _logger.LogWarning(ex, "生成向量时出错,FAISS向量服务已禁用"); From fb6ea87ae83f60438897674242c90b3faaae44dd Mon Sep 17 00:00:00 2001 From: ModerRAS Date: Thu, 23 Apr 2026 10:14:52 +0800 Subject: [PATCH 5/7] fix(import): support text-only Telegram exports Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Service/Manage/ChatImportServiceTests.cs | 147 ++++++++++ .../Model/ChatExport/ChatExport.cs | 130 +++++++++ .../Service/Manage/ChatImportService.cs | 273 +++++++++++++----- 3 files changed, 474 insertions(+), 76 deletions(-) create mode 100644 TelegramSearchBot.Test/Service/Manage/ChatImportServiceTests.cs 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..805aab8d 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 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) { + serializer.Serialize(writer, value); + } + + 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..9bee9092 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,177 @@ 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) { + 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) { From 72df29f8dedf33ec8bc656a4e42770569952da3c Mon Sep 17 00:00:00 2001 From: ModerRAS Date: Thu, 23 Apr 2026 10:25:29 +0800 Subject: [PATCH 6/7] fix(import): harden Telegram export parsing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- TelegramSearchBot/Model/ChatExport/ChatExport.cs | 6 +++--- TelegramSearchBot/Service/Manage/ChatImportService.cs | 4 ++++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/TelegramSearchBot/Model/ChatExport/ChatExport.cs b/TelegramSearchBot/Model/ChatExport/ChatExport.cs index 805aab8d..846e476c 100644 --- a/TelegramSearchBot/Model/ChatExport/ChatExport.cs +++ b/TelegramSearchBot/Model/ChatExport/ChatExport.cs @@ -131,6 +131,8 @@ public class ReactionInfo { } public class TextItemListConverter : JsonConverter { + public override bool CanWrite => false; + public override bool CanConvert(Type objectType) { return objectType == typeof(List); } @@ -169,9 +171,7 @@ public override object ReadJson(JsonReader reader, Type objectType, object exist return items; } - public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) { - serializer.Serialize(writer, value); - } + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) { } private static TextItem DeserializeTextItem(JToken token, JsonSerializer serializer) { if (token.Type == JTokenType.String) { diff --git a/TelegramSearchBot/Service/Manage/ChatImportService.cs b/TelegramSearchBot/Service/Manage/ChatImportService.cs index 9bee9092..ddc1604a 100644 --- a/TelegramSearchBot/Service/Manage/ChatImportService.cs +++ b/TelegramSearchBot/Service/Manage/ChatImportService.cs @@ -224,6 +224,10 @@ private static string BuildCaptionText(ExportMessage message) { } private static string FormatEntity(TextEntity entity) { + if (string.IsNullOrEmpty(entity.Text)) { + return string.Empty; + } + return entity.Type switch { "plain" => entity.Text, "bold" => $"**{entity.Text}**", From cf0a77577a3ec263f73d97a3b0733739a61d09c8 Mon Sep 17 00:00:00 2001 From: ModerRAS Date: Thu, 23 Apr 2026 10:34:47 +0800 Subject: [PATCH 7/7] test: stabilize MCP tool helper tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../McpToolHelperTestCollection.cs | 8 +++++ .../Service/AI/LLM/McpToolHelperTests.cs | 32 ++++++++++--------- .../Tools/McpInstallerToolServiceTests.cs | 4 +++ 3 files changed, 29 insertions(+), 15 deletions(-) create mode 100644 TelegramSearchBot.LLM.Test/McpToolHelperTestCollection.cs 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(); }