diff --git a/Docs/Architecture_Overview.md b/Docs/Architecture_Overview.md index b6b00512..41108995 100644 --- a/Docs/Architecture_Overview.md +++ b/Docs/Architecture_Overview.md @@ -74,8 +74,9 @@ TelegramSearchBot 是一个功能丰富的 Telegram 机器人,提供消息搜 ### 本地 Bot API 服务 - **内置 Telegram Bot API**: 可选启用内置 Bot API 服务 +- **外部 Local API**: 也可连接到已在本机其他位置运行的 Local Bot API - **优势**: 支持最大 2GB 文件发送(vs 云端 50MB) -- **配置**: 需要 `TelegramBotApiId` 和 `TelegramBotApiHash`(从 my.telegram.org 获取) +- **配置**: 内置模式需要 `TelegramBotApiId` 和 `TelegramBotApiHash`(从 my.telegram.org 获取);外部模式使用 `ExternalLocalBotApiBaseUrl` ## 平台兼容性 @@ -269,4 +270,4 @@ TelegramSearchBot/ --- *最后更新: 2026-03-26* -*文档版本: 1.1* \ No newline at end of file +*文档版本: 1.1* diff --git a/Docs/README_MCP.md b/Docs/README_MCP.md index 1edb4956..44d18c4e 100644 --- a/Docs/README_MCP.md +++ b/Docs/README_MCP.md @@ -51,7 +51,7 @@ TelegramSearchBot 内置了以下工具,通过 `BuiltInToolAttribute` 标记 | `send_document_file` | 发送文件(使用文件路径) | `file_path`, `caption?`, `reply_to_message_id?` | **文件大小限制**: -- 本地Bot API (`EnableLocalBotAPI=true`): 最大 2GB +- 本地 Bot API(内置或外部): 最大 2GB - 云端API: 最大 50MB ### 2.3 搜索工具 diff --git a/README.md b/README.md index f96672ea..259dc4fa 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ "EnableAutoASR": false, "IsLocalAPI": false, "EnableLocalBotAPI": false, + "ExternalLocalBotApiBaseUrl": "", "TelegramBotApiId": "", "TelegramBotApiHash": "", "LocalBotApiPort": 8081, @@ -80,11 +81,13 @@ - `AdminId`: 管理员Telegram用户ID(必须为数字) - **本地Bot API服务**: - - `EnableLocalBotAPI`: 是否启用内置Telegram Bot API服务(默认false) + - `EnableLocalBotAPI`: 是否启用内置 Telegram Bot API 服务(默认false) + - `ExternalLocalBotApiBaseUrl`: 外部本地 Bot API 地址;配置后会自动按本地 API 模式运行,无需再手动设置 `BaseUrl`/`IsLocalAPI` - `TelegramBotApiId`: Telegram Bot API的API ID(从my.telegram.org获取) - `TelegramBotApiHash`: Telegram Bot API的API Hash(从my.telegram.org获取) - `LocalBotApiPort`: 本地Bot API服务端口(默认8081) - - **优势**: 启用后可发送最大2GB文件(vs 云端50MB限制) + - **说明**: `EnableLocalBotAPI=true` 时会启动内置 `telegram-bot-api.exe`;若使用外部 local API,请保持 `EnableLocalBotAPI=false` 并填写 `ExternalLocalBotApiBaseUrl` + - **优势**: 使用本地 Bot API(内置或外部)后可发送最大2GB文件(vs 云端50MB限制) - **AI相关**: - `OllamaModelName`: 本地模型名称(默认"qwen2.5:72b-instruct-q2_K") diff --git a/TelegramSearchBot.Common/Env.cs b/TelegramSearchBot.Common/Env.cs index 3fcee22b..6344aa27 100644 --- a/TelegramSearchBot.Common/Env.cs +++ b/TelegramSearchBot.Common/Env.cs @@ -11,52 +11,85 @@ static Env() { if (!Directory.Exists(WorkDir)) { Directory.CreateDirectory(WorkDir); } + var config = new Config(); try { - var config = JsonConvert.DeserializeObject(File.ReadAllText(Path.Combine(WorkDir, "Config.json"))); - if (config is null) return; - 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; + var loadedConfig = JsonConvert.DeserializeObject(File.ReadAllText(Path.Combine(WorkDir, "Config.json"))); + if (loadedConfig is not null) { + config = loadedConfig; } - BotToken = config.BotToken; - AdminId = config.AdminId; - EnableAutoOCR = config.EnableAutoOCR; - EnableAutoASR = config.EnableAutoASR; - //WorkDir = config.WorkDir; - TaskDelayTimeout = config.TaskDelayTimeout; - SameServer = config.SameServer; - OllamaModelName = config.OllamaModelName; - EnableVideoASR = config.EnableVideoASR; - EnableOpenAI = config.EnableOpenAI; - OpenAIModelName = config.OpenAIModelName; - OLTPAuth = config.OLTPAuth; - OLTPAuthUrl = config.OLTPAuthUrl; - OLTPName = config.OLTPName; - BraveApiKey = config.BraveApiKey; - EnableAccounting = config.EnableAccounting; - MaxToolCycles = config.MaxToolCycles; - EnableLLMAgentProcess = config.EnableLLMAgentProcess; - AgentHeartbeatIntervalSeconds = config.AgentHeartbeatIntervalSeconds; - AgentHeartbeatTimeoutSeconds = config.AgentHeartbeatTimeoutSeconds; - AgentChunkPollingIntervalMilliseconds = config.AgentChunkPollingIntervalMilliseconds; - AgentIdleTimeoutMinutes = config.AgentIdleTimeoutMinutes; - MaxConcurrentAgents = config.MaxConcurrentAgents; - AgentTaskTimeoutSeconds = config.AgentTaskTimeoutSeconds; - AgentShutdownGracePeriodSeconds = config.AgentShutdownGracePeriodSeconds; - AgentMaxRecoveryAttempts = config.AgentMaxRecoveryAttempts; - AgentQueueBacklogWarningThreshold = config.AgentQueueBacklogWarningThreshold; - AgentProcessMemoryLimitMb = config.AgentProcessMemoryLimitMb; } catch { } + var botApiEndpoint = ResolveBotApiEndpoint(config); + EnableLocalBotAPI = config.EnableLocalBotAPI; + TelegramBotApiId = config.TelegramBotApiId; + TelegramBotApiHash = config.TelegramBotApiHash; + LocalBotApiPort = config.LocalBotApiPort; + ExternalLocalBotApiBaseUrl = botApiEndpoint.ExternalLocalBotApiBaseUrl; + BaseUrl = botApiEndpoint.BaseUrl; + IsLocalAPI = botApiEndpoint.IsLocalApi; + BotToken = config.BotToken; + AdminId = config.AdminId; + EnableAutoOCR = config.EnableAutoOCR; + EnableAutoASR = config.EnableAutoASR; + //WorkDir = config.WorkDir; + TaskDelayTimeout = config.TaskDelayTimeout; + SameServer = config.SameServer; + OllamaModelName = config.OllamaModelName; + EnableVideoASR = config.EnableVideoASR; + EnableOpenAI = config.EnableOpenAI; + OpenAIModelName = config.OpenAIModelName; + OLTPAuth = config.OLTPAuth; + OLTPAuthUrl = config.OLTPAuthUrl; + OLTPName = config.OLTPName; + BraveApiKey = config.BraveApiKey; + EnableAccounting = config.EnableAccounting; + MaxToolCycles = config.MaxToolCycles; + EnableLLMAgentProcess = config.EnableLLMAgentProcess; + AgentHeartbeatIntervalSeconds = config.AgentHeartbeatIntervalSeconds; + AgentHeartbeatTimeoutSeconds = config.AgentHeartbeatTimeoutSeconds; + AgentChunkPollingIntervalMilliseconds = config.AgentChunkPollingIntervalMilliseconds; + AgentIdleTimeoutMinutes = config.AgentIdleTimeoutMinutes; + MaxConcurrentAgents = config.MaxConcurrentAgents; + AgentTaskTimeoutSeconds = config.AgentTaskTimeoutSeconds; + AgentShutdownGracePeriodSeconds = config.AgentShutdownGracePeriodSeconds; + AgentMaxRecoveryAttempts = config.AgentMaxRecoveryAttempts; + AgentQueueBacklogWarningThreshold = config.AgentQueueBacklogWarningThreshold; + AgentProcessMemoryLimitMb = config.AgentProcessMemoryLimitMb; } + + public static BotApiEndpointSettings ResolveBotApiEndpoint(Config config) { + ArgumentNullException.ThrowIfNull(config); + + var normalizedExternalLocalBotApiBaseUrl = NormalizeBaseUrl(config.ExternalLocalBotApiBaseUrl, string.Empty); + if (config.EnableLocalBotAPI) { + return new BotApiEndpointSettings( + $"http://127.0.0.1:{config.LocalBotApiPort}", + true, + normalizedExternalLocalBotApiBaseUrl); + } + + if (!string.IsNullOrWhiteSpace(normalizedExternalLocalBotApiBaseUrl)) { + return new BotApiEndpointSettings( + normalizedExternalLocalBotApiBaseUrl, + true, + normalizedExternalLocalBotApiBaseUrl); + } + + return new BotApiEndpointSettings( + NormalizeBaseUrl(config.BaseUrl, "https://api.telegram.org"), + config.IsLocalAPI, + normalizedExternalLocalBotApiBaseUrl); + } + + private static string NormalizeBaseUrl(string? baseUrl, string fallback) { + if (string.IsNullOrWhiteSpace(baseUrl)) { + return fallback; + } + + return baseUrl.Trim().TrimEnd('/'); + } + public static readonly string BaseUrl = null!; #pragma warning disable CS8604 // 引用类型参数可能为 null。 public static readonly bool IsLocalAPI; @@ -69,6 +102,7 @@ static Env() { public static readonly string TelegramBotApiId = null!; public static readonly string TelegramBotApiHash = null!; public static readonly int LocalBotApiPort; + public static readonly string ExternalLocalBotApiBaseUrl = string.Empty; public static readonly string WorkDir = null!; public static readonly int TaskDelayTimeout; public static readonly bool SameServer; @@ -107,6 +141,7 @@ public class Config { //public string WorkDir { get; set; } = "/data/TelegramSearchBot"; public bool IsLocalAPI { get; set; } = false; public bool EnableLocalBotAPI { get; set; } = false; + public string ExternalLocalBotApiBaseUrl { get; set; } = string.Empty; public string TelegramBotApiId { get; set; } = null!; public string TelegramBotApiHash { get; set; } = null!; public int LocalBotApiPort { get; set; } = 8081; @@ -134,4 +169,6 @@ public class Config { public int AgentQueueBacklogWarningThreshold { get; set; } = 20; public int AgentProcessMemoryLimitMb { get; set; } = 256; } + + public sealed record BotApiEndpointSettings(string BaseUrl, bool IsLocalApi, string ExternalLocalBotApiBaseUrl); } diff --git a/TelegramSearchBot.Test/Common/EnvBotApiEndpointTests.cs b/TelegramSearchBot.Test/Common/EnvBotApiEndpointTests.cs new file mode 100644 index 00000000..49d2633d --- /dev/null +++ b/TelegramSearchBot.Test/Common/EnvBotApiEndpointTests.cs @@ -0,0 +1,45 @@ +using TelegramSearchBot.Common; +using Xunit; + +namespace TelegramSearchBot.Test.Common { + public class EnvBotApiEndpointTests { + [Fact] + public void ResolveBotApiEndpoint_UsesEmbeddedLocalApiWhenEnabled() { + var result = Env.ResolveBotApiEndpoint(new Config { + BaseUrl = "https://api.telegram.org", + EnableLocalBotAPI = true, + LocalBotApiPort = 8081, + ExternalLocalBotApiBaseUrl = "http://127.0.0.1:8082/" + }); + + Assert.Equal("http://127.0.0.1:8081", result.BaseUrl); + Assert.True(result.IsLocalApi); + Assert.Equal("http://127.0.0.1:8082", result.ExternalLocalBotApiBaseUrl); + } + + [Fact] + public void ResolveBotApiEndpoint_UsesExternalLocalApiWhenConfigured() { + var result = Env.ResolveBotApiEndpoint(new Config { + BaseUrl = "https://api.telegram.org", + IsLocalAPI = false, + EnableLocalBotAPI = false, + ExternalLocalBotApiBaseUrl = "http://127.0.0.1:8082/" + }); + + Assert.Equal("http://127.0.0.1:8082", result.BaseUrl); + Assert.True(result.IsLocalApi); + } + + [Fact] + public void ResolveBotApiEndpoint_FallsBackToConfiguredBaseUrlWhenNoLocalApiConfigured() { + var result = Env.ResolveBotApiEndpoint(new Config { + BaseUrl = "https://api.telegram.org/", + IsLocalAPI = false, + EnableLocalBotAPI = false + }); + + Assert.Equal("https://api.telegram.org", result.BaseUrl); + Assert.False(result.IsLocalApi); + } + } +} diff --git a/TelegramSearchBot/AppBootstrap/AppBootstrap.cs b/TelegramSearchBot/AppBootstrap/AppBootstrap.cs index 28ed6846..13bb744f 100644 --- a/TelegramSearchBot/AppBootstrap/AppBootstrap.cs +++ b/TelegramSearchBot/AppBootstrap/AppBootstrap.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.ComponentModel; using System.Diagnostics; +using System.IO; using System.Linq; using System.Reflection; using System.Reflection.Metadata; @@ -17,11 +18,17 @@ namespace TelegramSearchBot.AppBootstrap { public class AppBootstrap { public sealed class ChildProcessManager : IDisposable { private readonly List _handles = []; + private readonly List _processes = []; private bool _disposed; public void Dispose() { if (_disposed) return; + foreach (var process in _processes) { + process.Dispose(); + } + _processes.Clear(); + foreach (var handle in _handles) { handle.Dispose(); } @@ -44,11 +51,13 @@ public void AddProcess(SafeProcessHandle processHandle, long? processMemoryLimit } public void AddProcess(Process process, long? processMemoryLimitBytes = null) { + ValidateDisposed(); AddProcess(process.SafeHandle, processMemoryLimitBytes); + _processes.Add(process); } public void AddProcess(int processId, long? processMemoryLimitBytes = null) { - using var process = Process.GetProcessById(processId); + var process = Process.GetProcessById(processId); AddProcess(process, processMemoryLimitBytes); } @@ -170,26 +179,8 @@ private enum JobObjectLimitFlags : uint { public static ChildProcessManager childProcessManager = new ChildProcessManager(); public static Process Fork(string[] args, long? processMemoryLimitBytes = null) { string exePath = Environment.ProcessPath; - - // 将参数数组转换为空格分隔的字符串,并正确处理包含空格的参数 - string arguments = string.Join(" ", args.Select(arg => $"{arg}")); - - // 启动新的进程(自己) - ProcessStartInfo startInfo = new ProcessStartInfo { - FileName = exePath, - Arguments = arguments, - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true - }; - - var newProcess = Process.Start(startInfo); - if (newProcess == null) { - throw new Exception("启动新进程失败"); - } - childProcessManager.AddProcess(newProcess, processMemoryLimitBytes); - Log.Logger.Information($"主进程:{string.Join(" ", args)}已启动"); - return newProcess; + var startInfo = CreateManagedStartInfo(exePath, args, Environment.CurrentDirectory); + return StartManagedProcess(startInfo, $"主进程:{string.Join(" ", args)}", processMemoryLimitBytes); } private static Dictionary ForkLock = new Dictionary(); private static readonly AsyncLock _asyncLock = new AsyncLock(); @@ -208,30 +199,14 @@ public static async Task RateLimitForkAsync(string[] args) { } public static Process Fork(string exePath, string[] args, long? processMemoryLimitBytes = null) { - // 将参数数组转换为空格分隔的字符串,并正确处理包含空格的参数 - string arguments = string.Join(" ", args.Select(arg => $"{arg}")); - - // 启动新的进程(自己) - ProcessStartInfo startInfo = new ProcessStartInfo { - FileName = exePath, - Arguments = arguments, - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true - }; - - var newProcess = Process.Start(startInfo); - if (newProcess == null) { - throw new Exception("启动新进程失败"); - } - childProcessManager.AddProcess(newProcess, processMemoryLimitBytes); - Log.Logger.Information($"进程:{exePath} {string.Join(" ", args)}已启动"); - return newProcess; + var workingDirectory = Path.GetDirectoryName(exePath); + var startInfo = CreateManagedStartInfo(exePath, args, string.IsNullOrWhiteSpace(workingDirectory) ? Environment.CurrentDirectory : workingDirectory); + return StartManagedProcess(startInfo, $"进程:{exePath} {string.Join(" ", args)}", processMemoryLimitBytes); } public static async Task RateLimitForkAsync(string exePath, string[] args) { using (await _asyncLock.LockAsync()) { if (ForkLock.ContainsKey(exePath)) { - if (DateTime.UtcNow - ForkLock[args[0]] > TimeSpan.FromMinutes(5)) { + if (DateTime.UtcNow - ForkLock[exePath] > TimeSpan.FromMinutes(5)) { Fork(exePath, args); ForkLock[exePath] = DateTime.UtcNow; } @@ -249,6 +224,60 @@ public static async Task RateLimitForkAsync(string exePath, string[] args) { private const string TargetNamespace = "TelegramSearchBot.AppBootstrap"; + private static ProcessStartInfo CreateManagedStartInfo(string exePath, IEnumerable args, string workingDirectory) { + var startInfo = new ProcessStartInfo { + FileName = exePath, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + StandardOutputEncoding = Encoding.UTF8, + StandardErrorEncoding = Encoding.UTF8, + CreateNoWindow = true, + WorkingDirectory = workingDirectory + }; + + foreach (var arg in args) { + startInfo.ArgumentList.Add(arg); + } + + return startInfo; + } + + private static Process StartManagedProcess(ProcessStartInfo startInfo, string processDisplayName, long? processMemoryLimitBytes) { + var process = new Process { + StartInfo = startInfo, + EnableRaisingEvents = true + }; + + process.OutputDataReceived += (_, e) => { + if (!string.IsNullOrWhiteSpace(e.Data)) { + Log.Logger.Information("[{Process}] {Message}", processDisplayName, e.Data); + } + }; + process.ErrorDataReceived += (_, e) => { + if (!string.IsNullOrWhiteSpace(e.Data)) { + Log.Logger.Warning("[{Process}] {Message}", processDisplayName, e.Data); + } + }; + process.Exited += (_, _) => { + try { + Log.Logger.Information("[{Process}] exited with code {ExitCode}", processDisplayName, process.ExitCode); + } catch { + } + }; + + if (!process.Start()) { + throw new Exception("启动新进程失败"); + } + + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + childProcessManager.AddProcess(process, processMemoryLimitBytes); + Log.Logger.Information("{Process}已启动", processDisplayName); + return process; + } + /// /// 尝试根据第一个命令行参数通过反射查找并执行相应的 Bootstrap 类的 Startup 方法。 /// diff --git a/TelegramSearchBot/AppBootstrap/GeneralBootstrap.cs b/TelegramSearchBot/AppBootstrap/GeneralBootstrap.cs index c31422d9..6671fe4e 100644 --- a/TelegramSearchBot/AppBootstrap/GeneralBootstrap.cs +++ b/TelegramSearchBot/AppBootstrap/GeneralBootstrap.cs @@ -42,37 +42,74 @@ public class GeneralBootstrap : AppBootstrap { private static IServiceProvider service; /// - /// 等待 Garnet 服务端口就绪,最多等待 10 秒 + /// 等待 TCP 服务端口就绪 /// - private static async Task WaitForGarnetReady(int port, int maxRetries = 20, int delayMs = 500) { + private static async Task WaitForTcpServiceReady(string host, int port, string serviceName, Process? process = null, int maxRetries = 20, 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("Garnet 服务已就绪 (端口 {Port}),耗时约 {ElapsedMs}ms", port, i * delayMs); + if (process is { HasExited: true }) { + Log.Warning("{ServiceName} 进程在端口就绪前退出,退出码 {ExitCode}", serviceName, process.ExitCode); return; - } catch { - await Task.Delay(delayMs); } - } - 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); + await tcp.ConnectAsync(host, port); + Log.Information("{ServiceName} 服务已就绪 ({Host}:{Port}),耗时约 {ElapsedMs}ms", serviceName, host, port, i * delayMs); return; } catch { await Task.Delay(delayMs); } } - Log.Warning("等待 telegram-bot-api 服务就绪超时 (端口 {Port}),将继续启动", port); + Log.Warning("等待 {ServiceName} 服务就绪超时 ({Host}:{Port}),将继续启动", serviceName, host, port); + } + + private static bool TryGetLoopbackLocalBotApiUri(out Uri? uri) { + if (!Env.IsLocalAPI || !Uri.TryCreate(Env.BaseUrl, UriKind.Absolute, out uri)) { + uri = null; + return false; + } + + return uri.IsLoopback || string.Equals(uri.Host, "localhost", StringComparison.OrdinalIgnoreCase); + } + + private static async Task StartEmbeddedLocalBotApiAsync() { + string botApiExePath = Path.Combine(AppContext.BaseDirectory, "telegram-bot-api.exe"); + if (!File.Exists(botApiExePath)) { + Log.Warning("未找到 telegram-bot-api 可执行文件 {Path},跳过内置本地 Bot API 启动", botApiExePath); + return; + } + + if (string.IsNullOrWhiteSpace(Env.TelegramBotApiId) || string.IsNullOrWhiteSpace(Env.TelegramBotApiHash)) { + Log.Warning("EnableLocalBotAPI 为 true,但 TelegramBotApiId 或 TelegramBotApiHash 未配置,跳过内置本地 Bot API 启动"); + return; + } + + var botApiDataDir = Path.Combine(Env.WorkDir, "telegram-bot-api"); + Directory.CreateDirectory(botApiDataDir); + + var botApiProcess = Fork(botApiExePath, [ + "--local", + $"--api-id={Env.TelegramBotApiId}", + $"--api-hash={Env.TelegramBotApiHash}", + $"--dir={botApiDataDir}", + $"--http-port={Env.LocalBotApiPort}" + ]); + + Log.Information("内置 telegram-bot-api 已启动,等待端口 {Port} 就绪...", Env.LocalBotApiPort); + await WaitForTcpServiceReady("127.0.0.1", Env.LocalBotApiPort, "telegram-bot-api", botApiProcess, maxRetries: 40); + } + + private static async Task WaitForExternalLocalBotApiIfNeededAsync() { + if (!TryGetLoopbackLocalBotApiUri(out var localBotApiUri) || localBotApiUri == null || Env.EnableLocalBotAPI) { + return; + } + + var port = localBotApiUri.IsDefaultPort + ? ( localBotApiUri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) ? 443 : 80 ) + : localBotApiUri.Port; + + Log.Information("使用外部本地 Bot API: {BaseUrl}", Env.BaseUrl); + await WaitForTcpServiceReady(localBotApiUri.Host, port, "external telegram-bot-api", maxRetries: 40); } public static IHostBuilder CreateHostBuilder(string[] args) => @@ -91,45 +128,15 @@ public static async Task Startup(string[] args) { #if DEBUG Env.SchedulerPort = 6379; #endif - Fork(["Scheduler", $"{Env.SchedulerPort}"]); + var schedulerProcess = Fork(["Scheduler", $"{Env.SchedulerPort}"]); // 等待 Garnet 服务就绪,避免竞态条件导致 Redis 连接失败 - await WaitForGarnetReady(Env.SchedulerPort); + await WaitForTcpServiceReady("127.0.0.1", Env.SchedulerPort, "Garnet", schedulerProcess); - // 如果启用了本地 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); - } + await StartEmbeddedLocalBotApiAsync(); + } else { + await WaitForExternalLocalBotApiIfNeededAsync(); } IHost host = CreateHostBuilder(args)