Skip to content
8 changes: 8 additions & 0 deletions TelegramSearchBot.LLM.Test/McpToolHelperTestCollection.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using Xunit;

namespace TelegramSearchBot.Test {
[CollectionDefinition(Name, DisableParallelization = true)]
public sealed class McpToolHelperTestCollection {
public const string Name = "McpToolHelper serial";
}
}
32 changes: 17 additions & 15 deletions TelegramSearchBot.LLM.Test/Service/AI/LLM/McpToolHelperTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ public static void ProcessThoughtAsync(
}

// --- Test Class ---
[Collection(McpToolHelperTestCollection.Name)]
public class McpToolHelperTests {
#pragma warning disable CS8618 // 单元测试中字段会在初始化方法中赋值
// 移除构造函数中的静态字段和 Mock 成员,改为在测试方法内部声明和设置
Expand Down Expand Up @@ -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]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ namespace TelegramSearchBot.Test.Service.Tools {
/// <summary>
/// Integration tests for McpInstallerToolService verifying the full add/list/remove/restart flow.
/// </summary>
[Collection(McpToolHelperTestCollection.Name)]
public class McpInstallerToolServiceTests : IDisposable {
private readonly ServiceProvider _serviceProvider;
private readonly McpServerManager _mcpServerManager;
Expand Down Expand Up @@ -61,6 +62,9 @@ public McpInstallerToolServiceTests() {
}

public void Dispose() {
McpToolHelper.RegisterExternalTools(
new List<(string, McpToolHelper.ExternalToolInfo)>(),
null);
_mcpServerManager.Dispose();
_serviceProvider.Dispose();
}
Expand Down
147 changes: 147 additions & 0 deletions TelegramSearchBot.Test/Service/Manage/ChatImportServiceTests.cs
Original file line number Diff line number Diff line change
@@ -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<DataDbContext> _dbContextOptions;

public ChatImportServiceTests() {
_dbContextOptions = new DbContextOptionsBuilder<DataDbContext>()
.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<ILogger<ChatImportService>>().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<ITelegramBotClient>().Object,
new Mock<ILogger<TelegramSearchBot.Manager.SendMessage>>().Object) {
}

public override Task AddTask(Func<Task> action, bool isGroup) {
return Task.CompletedTask;
}
}
}
}
130 changes: 130 additions & 0 deletions TelegramSearchBot/Model/ChatExport/ChatExport.cs
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
using System;
using System.Collections.Generic;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;

namespace TelegramSearchBot.Model.ChatExport {
public class ChatExport {
public string Name { get; set; }
public string Type { get; set; }
public long Id { get; set; }
public List<Message> Messages { get; set; }
[JsonProperty("messages_count")]
public int? MessagesCount { get; set; }
}

public class Message {
Expand All @@ -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<TextItem> Text { get; set; }
public List<TextEntity> Text_Entities { get; set; }
public string Edited { get; set; }
Expand All @@ -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<ReactionInfo> Reactions { get; set; }
[JsonProperty("via_bot")]
public string ViaBot { get; set; }
public string Caption { get; set; }
[JsonProperty("caption_entities")]
public List<TextEntity> Caption_Entities { get; set; }
}

public class TextItem {
Expand All @@ -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<PollOption> 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<TextItem>);
}

public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) {
if (reader.TokenType == JsonToken.Null) {
return new List<TextItem>();
}

var token = JToken.Load(reader);

if (token.Type == JTokenType.String) {
return new List<TextItem> {
new() {
Type = "plain",
Text = token.Value<string>() ?? string.Empty
}
};
}

if (token.Type == JTokenType.Object) {
return new List<TextItem> {
DeserializeTextItem(token, serializer)
};
}

if (token.Type != JTokenType.Array) {
return new List<TextItem>();
}

var items = new List<TextItem>();
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>() ?? string.Empty
};
}

return token.ToObject<TextItem>(serializer) ?? new TextItem {
Type = "plain",
Text = token.ToString(Formatting.None)
};
}
}
}
Loading
Loading