From 1758680f3dcff80cf740a70ca480de20fa5a47fe Mon Sep 17 00:00:00 2001 From: meoacgx Date: Mon, 22 Dec 2025 19:07:31 +0800 Subject: [PATCH] fix: stabilize bot polling and simplify tasks --- Dockerfile.local | 31 ++ docker-compose.yml | 3 +- .../ServiceCollectionExtensions.cs | 3 + .../Services/Telegram/BotTelegramService.cs | 39 +- .../Services/Telegram/BotUpdateHub.cs | 408 ++++++++++++++++++ .../Services/Telegram/TelegramBotApiClient.cs | 71 +++ .../Services/Telegram/TelegramClientPool.cs | 22 +- .../ServiceCollectionExtensions.cs | 6 +- .../Components/Pages/BotChannelsHome.razor | 5 +- .../Components/Pages/Modules.razor | 4 +- .../Components/Pages/Tasks.razor | 103 ----- src/TelegramPanel.Web/Program.cs | 31 +- .../Services/BotAutoSyncBackgroundService.cs | 85 +++- tools/package-module.ps1 | 1 + 14 files changed, 664 insertions(+), 148 deletions(-) create mode 100644 Dockerfile.local create mode 100644 src/TelegramPanel.Core/Services/Telegram/BotUpdateHub.cs diff --git a/Dockerfile.local b/Dockerfile.local new file mode 100644 index 0000000..afd43ba --- /dev/null +++ b/Dockerfile.local @@ -0,0 +1,31 @@ +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +WORKDIR /src + +COPY . . + +RUN dotnet restore "TelegramPanel.sln" +RUN dotnet publish "src/TelegramPanel.Web/TelegramPanel.Web.csproj" -c Release -o /app/publish --no-restore + +# 说明: +# - 正常生产部署建议使用 mcr.microsoft.com/dotnet/aspnet:8.0 作为运行时镜像(更小) +# - 若当前网络环境导致 Docker/WSL 无法拉取 aspnet 镜像,可临时使用本文件(运行时使用 sdk 镜像) +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS final +WORKDIR /app + +ENV ASPNETCORE_URLS=http://+:5000 +EXPOSE 5000 + +# 持久化目录:/data(通过 docker-compose 挂载) +# - 数据库:/data/telegram-panel.db +# - session:/data/sessions/ +# - 本地配置:/data/appsettings.local.json(UI 保存 Telegram ApiId/ApiHash/同步开关等) +# - 后台密码:/data/admin_auth.json +RUN mkdir -p /data /data/sessions /data/logs \ + && rm -rf /app/logs \ + && ln -s /data/logs /app/logs \ + && ln -s /data/appsettings.local.json /app/appsettings.local.json || true + +COPY --from=build /app/publish . + +ENTRYPOINT ["dotnet", "TelegramPanel.Web.dll"] + diff --git a/docker-compose.yml b/docker-compose.yml index ffe68e6..67e0c80 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,7 @@ services: telegram-panel: build: context: . - dockerfile: Dockerfile + dockerfile: ${TP_DOCKERFILE:-Dockerfile} container_name: telegram-panel restart: unless-stopped ports: @@ -27,4 +27,3 @@ services: # 默认关闭 bot 轮询自动同步(需要时可手动同步) Telegram__BotAutoSyncEnabled: "false" - diff --git a/src/TelegramPanel.Core/ServiceCollectionExtensions.cs b/src/TelegramPanel.Core/ServiceCollectionExtensions.cs index dea1c0c..00ab607 100644 --- a/src/TelegramPanel.Core/ServiceCollectionExtensions.cs +++ b/src/TelegramPanel.Core/ServiceCollectionExtensions.cs @@ -23,6 +23,9 @@ public static class ServiceCollectionExtensions services.AddScoped(); services.AddScoped(); + // Bot API updates(getUpdates)统一轮询与分发:避免 409 Conflict + services.AddSingleton(); + // 注册账号导入协调服务 services.AddScoped(); diff --git a/src/TelegramPanel.Core/Services/Telegram/BotTelegramService.cs b/src/TelegramPanel.Core/Services/Telegram/BotTelegramService.cs index f93e731..132b5bc 100644 --- a/src/TelegramPanel.Core/Services/Telegram/BotTelegramService.cs +++ b/src/TelegramPanel.Core/Services/Telegram/BotTelegramService.cs @@ -30,9 +30,19 @@ public class BotTelegramService } /// - /// 拉取 Bot API updates 并把 bot 新加入的频道写入数据库(同时确认 offset)。 + /// 兼容入口:旧版曾在这里直接 getUpdates 拉取 my_chat_member。 + /// 现在 getUpdates 已由 统一轮询与分发,避免 409 Conflict 与 offset 争抢。 /// - public async Task SyncBotChannelsAsync(int botId, CancellationToken cancellationToken) + public Task SyncBotChannelsAsync(int botId, CancellationToken cancellationToken) + { + _logger.LogInformation("SyncBotChannelsAsync 已迁移为自动同步(BotUpdateHub),不再在此处拉取 updates:botId={BotId}", botId); + return Task.FromResult(0); + } + + /// + /// 应用一批 my_chat_member updates,把 Bot 新加入/移除/升降权的频道写入数据库。 + /// + public async Task ApplyMyChatMemberUpdatesAsync(int botId, IReadOnlyList updates, CancellationToken cancellationToken) { var bot = await _botManagement.GetBotAsync(botId) ?? throw new InvalidOperationException($"机器人不存在:{botId}"); @@ -40,38 +50,17 @@ public class BotTelegramService if (!bot.IsActive) throw new InvalidOperationException("该机器人已停用"); - var token = bot.Token; - var offset = bot.LastUpdateId.HasValue ? bot.LastUpdateId.Value + 1 : (long?)null; - - var allowedUpdates = JsonSerializer.Serialize(new[] { "my_chat_member" }); - var result = await _api.CallAsync(token, "getUpdates", new Dictionary - { - ["offset"] = offset?.ToString(), - ["timeout"] = "0", - ["limit"] = "100", - ["allowed_updates"] = allowedUpdates - }, cancellationToken); - - if (result.ValueKind != JsonValueKind.Array) - return 0; - // Telegram 会针对同一个 chat 连续产生多条 my_chat_member(例如 member -> administrator), // 这里按 chat_id 去重并只应用“最后一次状态”,避免“新增两个”的错觉/重复写库。 var changesByChatId = new Dictionary(); - long? maxUpdateId = null; - foreach (var update in result.EnumerateArray()) + foreach (var update in updates) { cancellationToken.ThrowIfCancellationRequested(); if (update.ValueKind != JsonValueKind.Object) continue; - if (update.TryGetProperty("update_id", out var updateIdEl) && updateIdEl.TryGetInt64(out var updateId)) - { - maxUpdateId = maxUpdateId.HasValue ? Math.Max(maxUpdateId.Value, updateId) : updateId; - } - // 只处理 my_chat_member:Bot 被加入/移除/升降权 if (!update.TryGetProperty("my_chat_member", out var myChatMember)) continue; @@ -123,8 +112,6 @@ public class BotTelegramService affected++; } - if (maxUpdateId.HasValue) - bot.LastUpdateId = maxUpdateId.Value; bot.LastSyncAt = DateTime.UtcNow; await _botManagement.UpdateBotAsync(bot); diff --git a/src/TelegramPanel.Core/Services/Telegram/BotUpdateHub.cs b/src/TelegramPanel.Core/Services/Telegram/BotUpdateHub.cs new file mode 100644 index 0000000..3936d46 --- /dev/null +++ b/src/TelegramPanel.Core/Services/Telegram/BotUpdateHub.cs @@ -0,0 +1,408 @@ +using System.Collections.Concurrent; +using System.Text.Json; +using System.Threading.Channels; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using TelegramPanel.Data.Repositories; + +namespace TelegramPanel.Core.Services.Telegram; + +/// +/// Bot API 更新分发 Hub: +/// - 同一个 Bot Token 只允许一个 getUpdates 长轮询,否则会 409 Conflict +/// - 这里为每个 botId 维护一个单一轮询器,并把同一份 updates 广播给多个消费者(模块/后台服务) +/// +public sealed class BotUpdateHub : IAsyncDisposable +{ + // 固定允许的更新类型:覆盖当前项目使用场景(转发/监听/入群事件) + private const string AllowedUpdatesJson = "[\"message\",\"edited_message\",\"channel_post\",\"edited_channel_post\",\"my_chat_member\"]"; + + private readonly IServiceScopeFactory _scopeFactory; + private readonly TelegramBotApiClient _botApi; + private readonly ILogger _logger; + + private readonly SemaphoreSlim _gate = new(1, 1); + private readonly Dictionary _pollersByToken = new(StringComparer.Ordinal); + + public BotUpdateHub( + IServiceScopeFactory scopeFactory, + TelegramBotApiClient botApi, + ILogger logger) + { + _scopeFactory = scopeFactory; + _botApi = botApi; + _logger = logger; + } + + public async Task SubscribeAsync(int botId, CancellationToken cancellationToken) + { + if (botId <= 0) + throw new ArgumentException("botId 无效", nameof(botId)); + + cancellationToken.ThrowIfCancellationRequested(); + + await _gate.WaitAsync(cancellationToken); + try + { + using var scope = _scopeFactory.CreateScope(); + var botRepo = scope.ServiceProvider.GetRequiredService(); + var bot = await botRepo.GetByIdAsync(botId); + if (bot == null) + throw new InvalidOperationException($"Bot 不存在:{botId}"); + if (!bot.IsActive) + throw new InvalidOperationException("Bot 未启用"); + + var token = (bot.Token ?? "").Trim(); + if (string.IsNullOrWhiteSpace(token)) + throw new InvalidOperationException("Bot Token 为空"); + + if (!_pollersByToken.TryGetValue(token, out var poller)) + { + poller = await BotPoller.CreateAsync(botId, token, bot.LastUpdateId, _scopeFactory, _botApi, _logger, cancellationToken); + _pollersByToken[token] = poller; + } + + return poller.Subscribe(); + } + finally + { + _gate.Release(); + } + } + + public async ValueTask DisposeAsync() + { + List pollers; + + await _gate.WaitAsync(); + try + { + pollers = _pollersByToken.Values.ToList(); + _pollersByToken.Clear(); + } + finally + { + _gate.Release(); + } + + foreach (var p in pollers) + { + try { await p.DisposeAsync(); } + catch (Exception ex) { _logger.LogWarning(ex, "Dispose bot poller failed: {BotId}", p.BotId); } + } + } + + public sealed class BotUpdateSubscription : IAsyncDisposable + { + private readonly Func _dispose; + + internal BotUpdateSubscription(int botId, ChannelReader reader, Func dispose) + { + BotId = botId; + Reader = reader; + _dispose = dispose; + } + + public int BotId { get; } + public ChannelReader Reader { get; } + + public ValueTask DisposeAsync() => _dispose(); + } + + private sealed class BotPoller : IAsyncDisposable + { + private static readonly BoundedChannelOptions SubscriberChannelOptions = new(512) + { + SingleWriter = true, + SingleReader = true, + FullMode = BoundedChannelFullMode.DropOldest + }; + + private readonly int _persistBotId; + private readonly string _token; + private readonly IServiceScopeFactory _scopeFactory; + private readonly TelegramBotApiClient _botApi; + private readonly ILogger _logger; + + private readonly CancellationTokenSource _cts = new(); + private readonly Task _loopTask; + + private readonly object _subscribersLock = new(); + private readonly Dictionary> _subscribers = new(); + + private long _nextOffset; + + public int BotId => _persistBotId; + + private BotPoller( + int persistBotId, + string token, + long nextOffset, + IServiceScopeFactory scopeFactory, + TelegramBotApiClient botApi, + ILogger logger) + { + _persistBotId = persistBotId; + _token = token; + _nextOffset = nextOffset; + _scopeFactory = scopeFactory; + _botApi = botApi; + _logger = logger; + + _loopTask = Task.Run(() => RunAsync(_cts.Token)); + } + + public static async Task CreateAsync( + int botId, + string token, + long? lastUpdateId, + IServiceScopeFactory scopeFactory, + TelegramBotApiClient botApi, + ILogger logger, + CancellationToken cancellationToken) + { + // 初始化 offset: + // - 如果数据库里已有 LastUpdateId,则从其后开始 + // - 否则快进到最新,避免冷启动回放历史消息造成刷屏 + long nextOffset; + if (lastUpdateId.HasValue) + { + nextOffset = lastUpdateId.Value + 1; + } + else + { + nextOffset = await FastForwardOffsetAsync(botId, token, scopeFactory, botApi, logger, cancellationToken); + } + + return new BotPoller(botId, token, nextOffset, scopeFactory, botApi, logger); + } + + public BotUpdateSubscription Subscribe() + { + var id = Guid.NewGuid(); + var ch = Channel.CreateBounded(SubscriberChannelOptions); + + lock (_subscribersLock) + { + _subscribers[id] = ch; + } + + return new BotUpdateSubscription(_persistBotId, ch.Reader, async () => + { + Channel? removed = null; + lock (_subscribersLock) + { + if (_subscribers.Remove(id, out var existing)) + removed = existing; + } + + if (removed != null) + { + try { removed.Writer.TryComplete(); } + catch { /* ignore */ } + } + + await ValueTask.CompletedTask; + }); + } + + private async Task RunAsync(CancellationToken cancellationToken) + { + // 给启动期一点喘息 + try { await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken); } + catch (OperationCanceledException) { return; } + + var conflictStreak = 0; + + while (!cancellationToken.IsCancellationRequested) + { + try + { + // 没有订阅者时也继续拉取并确认 offset,避免积压导致后续刷屏; + // 同时还能保证 Bot 频道自动同步等后台能力可工作。 + + var updates = await _botApi.CallAsync(_token, "getUpdates", new Dictionary + { + ["offset"] = _nextOffset.ToString(), + ["timeout"] = "25", + ["limit"] = "100", + ["allowed_updates"] = AllowedUpdatesJson + }, cancellationToken); + + if (updates.ValueKind != JsonValueKind.Array) + continue; + + long? maxUpdateId = null; + foreach (var update in updates.EnumerateArray()) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (!TryGetUpdateId(update, out var updateId)) + continue; + + maxUpdateId = maxUpdateId.HasValue ? Math.Max(maxUpdateId.Value, updateId) : updateId; + + Broadcast(update); + } + + if (maxUpdateId.HasValue) + { + _nextOffset = maxUpdateId.Value + 1; + await SaveLastUpdateIdAsync(maxUpdateId.Value, cancellationToken); + } + + conflictStreak = 0; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + break; + } + catch (InvalidOperationException ex) when (ex.Message.Contains("getUpdates (409)", StringComparison.OrdinalIgnoreCase)) + { + // 典型原因: + // - 同一个 bot token 还有别的实例在 long-poll + // - 或者本进程有 bug 造成并发(理论上 Hub 已避免) + conflictStreak++; + var backoffSeconds = Math.Min(60, 2 * conflictStreak); + _logger.LogWarning(ex, "Bot getUpdates 409 conflict: botId={BotId}(请确保该 Bot Token 仅有一个实例在轮询 getUpdates)", _persistBotId); + await Task.Delay(TimeSpan.FromSeconds(backoffSeconds), cancellationToken); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Bot update poll loop failed: botId={BotId}", _persistBotId); + await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken); + } + } + } + + private void Broadcast(JsonElement update) + { + List> targets; + lock (_subscribersLock) + { + if (_subscribers.Count == 0) + return; + targets = _subscribers.Values.ToList(); + } + + foreach (var ch in targets) + { + try + { + // Clone:JsonElement 的生命周期绑定 JsonDocument + ch.Writer.TryWrite(update.Clone()); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Broadcast update failed: botId={BotId}", _persistBotId); + } + } + } + + private static bool TryGetUpdateId(JsonElement update, out long updateId) + { + updateId = 0; + if (!update.TryGetProperty("update_id", out var el)) + return false; + if (el.ValueKind != JsonValueKind.Number) + return false; + return el.TryGetInt64(out updateId); + } + + private async Task SaveLastUpdateIdAsync(long updateId, CancellationToken cancellationToken) + { + using var scope = _scopeFactory.CreateScope(); + var botRepo = scope.ServiceProvider.GetRequiredService(); + var bot = await botRepo.GetByIdAsync(_persistBotId); + if (bot == null) + return; + + if (!bot.LastUpdateId.HasValue || updateId > bot.LastUpdateId.Value) + { + bot.LastUpdateId = updateId; + await botRepo.UpdateAsync(bot); + } + } + + private static async Task FastForwardOffsetAsync( + int botId, + string token, + IServiceScopeFactory scopeFactory, + TelegramBotApiClient botApi, + ILogger logger, + CancellationToken cancellationToken) + { + long offset = 0; + + // 尝试最多 20 次(2000 条)以“清空积压”,避免首次启用模块直接刷历史。 + for (var i = 0; i < 20; i++) + { + cancellationToken.ThrowIfCancellationRequested(); + + var updates = await botApi.CallAsync(token, "getUpdates", new Dictionary + { + ["offset"] = offset.ToString(), + ["timeout"] = "0", + ["limit"] = "100", + ["allowed_updates"] = AllowedUpdatesJson + }, cancellationToken); + + if (updates.ValueKind != JsonValueKind.Array) + break; + + long? maxUpdateId = null; + foreach (var u in updates.EnumerateArray()) + { + if (!TryGetUpdateId(u, out var id)) + continue; + maxUpdateId = maxUpdateId.HasValue ? Math.Max(maxUpdateId.Value, id) : id; + } + + if (!maxUpdateId.HasValue) + break; + + offset = maxUpdateId.Value + 1; + } + + // 写入数据库(用于下次启动直接从最新开始) + try + { + using var scope = scopeFactory.CreateScope(); + var botRepo = scope.ServiceProvider.GetRequiredService(); + var bot = await botRepo.GetByIdAsync(botId); + if (bot != null && offset > 0) + { + var last = offset - 1; + if (!bot.LastUpdateId.HasValue || last > bot.LastUpdateId.Value) + { + bot.LastUpdateId = last; + await botRepo.UpdateAsync(bot); + } + } + } + catch (Exception ex) + { + logger.LogWarning(ex, "FastForwardOffset persistence failed: botId={BotId}", botId); + } + + return offset; + } + + public async ValueTask DisposeAsync() + { + _cts.Cancel(); + try { await _loopTask; } catch { /* ignore */ } + + lock (_subscribersLock) + { + foreach (var ch in _subscribers.Values) + { + try { ch.Writer.TryComplete(); } catch { /* ignore */ } + } + _subscribers.Clear(); + } + + _cts.Dispose(); + } + } +} diff --git a/src/TelegramPanel.Core/Services/Telegram/TelegramBotApiClient.cs b/src/TelegramPanel.Core/Services/Telegram/TelegramBotApiClient.cs index fe06bf2..e48db63 100644 --- a/src/TelegramPanel.Core/Services/Telegram/TelegramBotApiClient.cs +++ b/src/TelegramPanel.Core/Services/Telegram/TelegramBotApiClient.cs @@ -1,4 +1,5 @@ using System.Net.Http; +using System.Net.Http.Headers; using System.Text; using System.Text.Json; using Microsoft.Extensions.Logging; @@ -118,6 +119,76 @@ public class TelegramBotApiClient return result.Clone(); } + /// + /// 调用 Bot API 并上传多个文件(使用 multipart/form-data)。 + /// 典型用途:sendMediaGroup(attach://file1...)。 + /// + public async Task CallWithFilesAsync( + string token, + string method, + IReadOnlyDictionary parameters, + IReadOnlyDictionary files, + CancellationToken cancellationToken) + { + token = (token ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(token)) + throw new InvalidOperationException("Bot Token 为空"); + + method = (method ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(method)) + throw new InvalidOperationException("method 为空"); + + if (files == null || files.Count == 0) + throw new InvalidOperationException("files 为空"); + + var url = $"https://api.telegram.org/bot{Uri.EscapeDataString(token)}/{Uri.EscapeDataString(method)}"; + + using var content = new MultipartFormDataContent(); + + foreach (var (key, value) in parameters) + { + if (!string.IsNullOrWhiteSpace(key) && value != null) + content.Add(new StringContent(value), key); + } + + foreach (var (name, file) in files) + { + if (string.IsNullOrWhiteSpace(name)) + continue; + + var stream = file.Stream ?? throw new InvalidOperationException($"files[{name}] stream 为空"); + if (stream.CanSeek) + stream.Position = 0; + + var fileName = (file.FileName ?? "file").Trim(); + if (string.IsNullOrWhiteSpace(fileName)) + fileName = "file"; + + var streamContent = new StreamContent(stream); + streamContent.Headers.ContentType = new MediaTypeHeaderValue(string.IsNullOrWhiteSpace(file.ContentType) ? "application/octet-stream" : file.ContentType); + content.Add(streamContent, name, fileName); + } + + using var resp = await _http.PostAsync(url, content, cancellationToken); + var json = await resp.Content.ReadAsStringAsync(cancellationToken); + + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + var ok = root.TryGetProperty("ok", out var okEl) && okEl.ValueKind == JsonValueKind.True; + if (!ok) + { + var code = root.TryGetProperty("error_code", out var codeEl) ? codeEl.GetInt32() : 0; + var desc = root.TryGetProperty("description", out var descEl) ? descEl.GetString() : "未知错误"; + throw new InvalidOperationException($"Bot API 调用失败:{method} ({code}) {desc}"); + } + + if (!root.TryGetProperty("result", out var result)) + throw new InvalidOperationException($"Bot API 返回缺少 result:{method}"); + + return result.Clone(); + } + private static string BuildUrl(string token, string method, IReadOnlyDictionary query) { var sb = new StringBuilder(); diff --git a/src/TelegramPanel.Core/Services/Telegram/TelegramClientPool.cs b/src/TelegramPanel.Core/Services/Telegram/TelegramClientPool.cs index fee8043..a04051f 100644 --- a/src/TelegramPanel.Core/Services/Telegram/TelegramClientPool.cs +++ b/src/TelegramPanel.Core/Services/Telegram/TelegramClientPool.cs @@ -1,6 +1,7 @@ using System.Collections.Concurrent; using System.Linq; using System.Reflection; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using TelegramPanel.Core.Interfaces; using WTelegram; @@ -15,11 +16,13 @@ public class TelegramClientPool : ITelegramClientPool, IDisposable { private readonly ConcurrentDictionary _clients = new(); private readonly ConcurrentDictionary _locks = new(); + private readonly IConfiguration _configuration; private readonly ILogger _logger; private bool _disposed; - public TelegramClientPool(ILogger logger) + public TelegramClientPool(IConfiguration configuration, ILogger logger) { + _configuration = configuration; _logger = logger; } @@ -61,6 +64,12 @@ public class TelegramClientPool : ITelegramClientPool, IDisposable phoneNumber = NormalizePhone(phoneNumber); string Config(string what) { + var proxyServer = (_configuration["Telegram:Proxy:Server"] ?? "").Trim(); + var proxyPort = (_configuration["Telegram:Proxy:Port"] ?? "").Trim(); + var proxyUsername = (_configuration["Telegram:Proxy:Username"] ?? "").Trim(); + var proxyPassword = (_configuration["Telegram:Proxy:Password"] ?? "").Trim(); + var proxySecret = (_configuration["Telegram:Proxy:Secret"] ?? "").Trim(); + return what switch { "api_id" => apiId.ToString(), @@ -69,6 +78,17 @@ public class TelegramClientPool : ITelegramClientPool, IDisposable "session_key" => string.IsNullOrWhiteSpace(sessionKey) ? null! : sessionKey, "phone_number" => string.IsNullOrWhiteSpace(phoneNumber) ? null! : phoneNumber, "user_id" => userId.HasValue && userId.Value > 0 ? userId.Value.ToString() : null!, + + // 代理(可选):用于 Telegram MTProto 连接(WTelegramClient)。 + // 常见场景:宿主机能直连 Telegram,但 Docker/服务器环境需要代理。 + // - SOCKS5/HTTP:配置 server/port;如需认证再填 username/password + // - MTProto:额外配置 secret + "proxy_server" => string.IsNullOrWhiteSpace(proxyServer) ? null! : proxyServer, + "proxy_port" => string.IsNullOrWhiteSpace(proxyPort) ? null! : proxyPort, + "proxy_username" => string.IsNullOrWhiteSpace(proxyUsername) ? null! : proxyUsername, + "proxy_password" => string.IsNullOrWhiteSpace(proxyPassword) ? null! : proxyPassword, + "proxy_secret" => string.IsNullOrWhiteSpace(proxySecret) ? null! : proxySecret, + _ => null! // 使用 null! 抑制警告,这是 WTelegramClient 的预期行为 }; } diff --git a/src/TelegramPanel.Data/ServiceCollectionExtensions.cs b/src/TelegramPanel.Data/ServiceCollectionExtensions.cs index bec61fc..fcdc98c 100644 --- a/src/TelegramPanel.Data/ServiceCollectionExtensions.cs +++ b/src/TelegramPanel.Data/ServiceCollectionExtensions.cs @@ -13,7 +13,11 @@ public static class ServiceCollectionExtensions { // 注册数据库上下文 services.AddDbContext(options => - options.UseSqlite(connectionString)); + options.UseSqlite(connectionString, sqlite => + { + // 避免 Include 多个集合导航时的“笛卡尔爆炸”与性能警告 + sqlite.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery); + })); // 注册所有Repository services.AddScoped(); diff --git a/src/TelegramPanel.Web/Components/Pages/BotChannelsHome.razor b/src/TelegramPanel.Web/Components/Pages/BotChannelsHome.razor index a8f0ac6..df05000 100644 --- a/src/TelegramPanel.Web/Components/Pages/BotChannelsHome.razor +++ b/src/TelegramPanel.Web/Components/Pages/BotChannelsHome.razor @@ -261,7 +261,10 @@ try { var count = await BotTelegram.SyncBotChannelsAsync(selectedBotId, CancellationToken.None); - Snackbar.Add($"同步完成:影响 {count} 个频道", Severity.Success); + if (count > 0) + Snackbar.Add($"同步完成:影响 {count} 个频道", Severity.Success); + else + Snackbar.Add("已启用秒级自动同步:手动同步无需再拉取 updates", Severity.Info); await LoadBotData(); } catch (Exception ex) diff --git a/src/TelegramPanel.Web/Components/Pages/Modules.razor b/src/TelegramPanel.Web/Components/Pages/Modules.razor index f1ef02f..aa01c83 100644 --- a/src/TelegramPanel.Web/Components/Pages/Modules.razor +++ b/src/TelegramPanel.Web/Components/Pages/Modules.razor @@ -53,10 +53,10 @@ { 检测到模块贡献冲突(已自动忽略部分定义): - + @foreach (var d in Contributions.Diagnostics) { - @d + @d } diff --git a/src/TelegramPanel.Web/Components/Pages/Tasks.razor b/src/TelegramPanel.Web/Components/Pages/Tasks.razor index 267ce50..8290336 100644 --- a/src/TelegramPanel.Web/Components/Pages/Tasks.razor +++ b/src/TelegramPanel.Web/Components/Pages/Tasks.razor @@ -3,60 +3,14 @@ @inject TelegramPanel.Web.Modules.ModuleContributionRegistry Contributions @inject ISnackbar Snackbar @inject IDialogService DialogService -@inject NavigationManager Navigation @using TelegramPanel.Data.Entities @using TelegramPanel.Core.BatchTasks -@using TelegramPanel.Web.Modules @implements IDisposable 任务中心 - Telegram Panel 任务中心 -@if (ConfigurableTasks.Count > 0) -{ - - - 持续任务(可配置) - - 这类任务通常是模块提供的“常驻后台能力”,不占用批量任务队列;在这里可直接打开配置窗口编辑。 - - - - - 任务 - 归属 - 入口 - 操作 - - - - @context.DisplayName - @if (!string.IsNullOrWhiteSpace(context.Description)) - { - @context.Description - } - @context.TaskType - - - - @GetCategoryDisplayName(context.Category) - - - - @context.CreateRoute - - - - 编辑 - - - - - - -} - @@ -381,61 +335,4 @@ if (result is { Canceled: false }) await LoadTasks(); } - - private IReadOnlyList ConfigurableTasks => - Contributions.Tasks - .Select(x => x.Definition) - .Where(x => !IsSystemCategory(x.Category)) - .Where(x => !string.IsNullOrWhiteSpace(x.CreateRoute)) - .OrderBy(x => x.Order) - .ThenBy(x => x.DisplayName) - .ToList(); - - private static bool IsSystemCategory(string? category) - => string.Equals((category ?? "").Trim(), "system", StringComparison.OrdinalIgnoreCase); - - private async Task OpenTaskConfig(TelegramPanel.Modules.ModuleTaskDefinition def) - { - var route = (def.CreateRoute ?? "").Trim(); - if (route.Length == 0) - return; - - if (TryParseExtRoute(route, out var moduleId, out var pageKey)) - { - var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.ExtraLarge, FullWidth = true }; - var parameters = new DialogParameters - { - ["ModuleId"] = moduleId, - ["PageKey"] = pageKey - }; - - DialogService.Show("任务配置", parameters, options); - await Task.CompletedTask; - return; - } - - // fallback:不是模块扩展页面则直接跳转 - Navigation.NavigateTo(route); - } - - private static bool TryParseExtRoute(string route, out string moduleId, out string pageKey) - { - moduleId = ""; - pageKey = ""; - - route = (route ?? "").Trim(); - if (!route.StartsWith("/ext/", StringComparison.OrdinalIgnoreCase)) - return false; - - var parts = route.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - if (parts.Length < 3) - return false; - - if (!string.Equals(parts[0], "ext", StringComparison.OrdinalIgnoreCase)) - return false; - - moduleId = parts[1]; - pageKey = parts[2]; - return moduleId.Length > 0 && pageKey.Length > 0; - } } diff --git a/src/TelegramPanel.Web/Program.cs b/src/TelegramPanel.Web/Program.cs index 1597c28..c5b1506 100644 --- a/src/TelegramPanel.Web/Program.cs +++ b/src/TelegramPanel.Web/Program.cs @@ -12,6 +12,7 @@ using TelegramPanel.Data; using TelegramPanel.Web.Modules; using TelegramPanel.Web.Services; using Microsoft.Extensions.Logging; +using Microsoft.AspNetCore.DataProtection; // 诊断:对某个目录下的 *.json/*.session 做一次“可转换/可校验”检查(不写数据库) // 用法:dotnet run --project src/TelegramPanel.Web -- --diag-session-dir "D:/path/to/dir" @@ -248,6 +249,27 @@ builder.Services.AddRazorComponents() // MudBlazor builder.Services.AddMudServices(); +// DataProtection keys 持久化:避免容器重建/重启后出现 antiforgery token 无法解密 +try +{ + var configuredKeysPath = (builder.Configuration["DataProtection:KeysPath"] ?? "").Trim(); + var keysPath = configuredKeysPath; + if (string.IsNullOrWhiteSpace(keysPath)) + { + keysPath = Directory.Exists("/data") + ? "/data/keys" + : Path.Combine(builder.Environment.ContentRootPath, "data-protection-keys"); + } + + Directory.CreateDirectory(keysPath); + builder.Services.AddDataProtection() + .PersistKeysToFileSystem(new DirectoryInfo(keysPath)); +} +catch (Exception ex) +{ + Console.Error.WriteLine($"Failed to configure DataProtection keys persistence: {ex.Message}"); +} + // 反向代理支持(宝塔/Nginx/Caddy 等) // 让应用正确识别外部访问的 Host/Proto,避免重定向到 http://localhost/... builder.Services.Configure(options => @@ -543,7 +565,14 @@ if (!app.Environment.IsDevelopment()) app.UseForwardedHeaders(); -app.UseHttpsRedirection(); +// 仅在存在 HTTPS 端口/端点时启用重定向;否则会产生 “Failed to determine the https port for redirect.” 噪声 +var httpsPort = app.Configuration["ASPNETCORE_HTTPS_PORT"]; +var urls = app.Configuration["ASPNETCORE_URLS"] ?? ""; +var hasHttpsEndpoint = !string.IsNullOrWhiteSpace(httpsPort) + || urls.Contains("https://", StringComparison.OrdinalIgnoreCase) + || !string.IsNullOrWhiteSpace(app.Configuration["Kestrel:Endpoints:Https:Url"]); +if (hasHttpsEndpoint) + app.UseHttpsRedirection(); // 静态文件(包括 MudBlazor 等 NuGet 包的静态 Web 资源) app.UseStaticFiles(); diff --git a/src/TelegramPanel.Web/Services/BotAutoSyncBackgroundService.cs b/src/TelegramPanel.Web/Services/BotAutoSyncBackgroundService.cs index 9f3b82f..241312d 100644 --- a/src/TelegramPanel.Web/Services/BotAutoSyncBackgroundService.cs +++ b/src/TelegramPanel.Web/Services/BotAutoSyncBackgroundService.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using System.Text.Json; using TelegramPanel.Core.Services; using TelegramPanel.Core.Services.Telegram; @@ -14,13 +15,16 @@ public class BotAutoSyncBackgroundService : BackgroundService private readonly IServiceScopeFactory _scopeFactory; private readonly ILogger _logger; private readonly IConfiguration _configuration; + private readonly BotUpdateHub _updateHub; public BotAutoSyncBackgroundService( IServiceScopeFactory scopeFactory, + BotUpdateHub updateHub, IConfiguration configuration, ILogger logger) { _scopeFactory = scopeFactory; + _updateHub = updateHub; _configuration = configuration; _logger = logger; } @@ -35,22 +39,24 @@ public class BotAutoSyncBackgroundService : BackgroundService return; } - // 秒级轮询(近似“监听”效果);可通过配置调整 - var seconds = _configuration.GetValue("Telegram:BotAutoSyncIntervalSeconds", 5); + // 这里的 interval 用于:刷新 bot 列表 + 批量 drain 已收集的 updates(避免每条 update 都写 DB) + var seconds = _configuration.GetValue("Telegram:BotAutoSyncIntervalSeconds", 2); if (seconds < 2) seconds = 2; if (seconds > 60) seconds = 60; var interval = TimeSpan.FromSeconds(seconds); - _logger.LogInformation("Bot auto sync started, interval {IntervalSeconds} seconds", seconds); + _logger.LogInformation("Bot auto sync started (via BotUpdateHub), interval {IntervalSeconds} seconds", seconds); // 延迟一点,避免与启动时 DB 迁移抢资源 await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken); + var subscriptions = new Dictionary(); + while (!stoppingToken.IsCancellationRequested) { try { - await SyncOnceAsync(stoppingToken); + await SyncOnceAsync(subscriptions, stoppingToken); } catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { @@ -63,9 +69,17 @@ public class BotAutoSyncBackgroundService : BackgroundService await Task.Delay(interval, stoppingToken); } + + foreach (var sub in subscriptions.Values) + { + try { await sub.DisposeAsync(); } + catch { /* ignore */ } + } } - private async Task SyncOnceAsync(CancellationToken cancellationToken) + private async Task SyncOnceAsync( + Dictionary subscriptions, + CancellationToken cancellationToken) { using var scope = _scopeFactory.CreateScope(); var botManagement = scope.ServiceProvider.GetRequiredService(); @@ -75,20 +89,69 @@ public class BotAutoSyncBackgroundService : BackgroundService if (bots.Count == 0) return; + // 1) 确保订阅已建立(每个 botId 一个订阅即可) + var aliveBotIds = bots.Select(b => b.Id).ToHashSet(); foreach (var bot in bots) { cancellationToken.ThrowIfCancellationRequested(); + + if (subscriptions.ContainsKey(bot.Id)) + continue; + try { - var count = await botTelegram.SyncBotChannelsAsync(bot.Id, cancellationToken); - if (count > 0) - _logger.LogInformation("Bot auto sync: bot {BotId} synced {Count} channels", bot.Id, count); - else - _logger.LogDebug("Bot auto sync: bot {BotId} synced 0 channels", bot.Id); + subscriptions[bot.Id] = await _updateHub.SubscribeAsync(bot.Id, cancellationToken); } catch (Exception ex) { - _logger.LogWarning(ex, "Bot auto sync failed for bot {BotId}", bot.Id); + _logger.LogWarning(ex, "Bot auto sync subscribe failed for bot {BotId}", bot.Id); + } + } + + // 2) 清理已停用/已删除 bot 的订阅 + foreach (var staleId in subscriptions.Keys.Where(id => !aliveBotIds.Contains(id)).ToList()) + { + if (subscriptions.Remove(staleId, out var sub)) + { + try { await sub.DisposeAsync(); } + catch { /* ignore */ } + } + } + + // 3) 批量 drain:只取 my_chat_member,按 botId 分批应用(去重在 BotTelegramService 内部做) + foreach (var bot in bots) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (!subscriptions.TryGetValue(bot.Id, out var sub)) + continue; + + var batch = new List(); + while (sub.Reader.TryRead(out var update)) + { + if (update.ValueKind == System.Text.Json.JsonValueKind.Object + && update.TryGetProperty("my_chat_member", out _)) + { + batch.Add(update); + if (batch.Count >= 200) + break; + } + } + + if (batch.Count == 0) + continue; + + try + { + var count = await botTelegram.ApplyMyChatMemberUpdatesAsync(bot.Id, batch, cancellationToken); + if (count > 0) + _logger.LogInformation("Bot auto sync: bot {BotId} applied {Count} updates", bot.Id, count); + else + _logger.LogDebug("Bot auto sync: bot {BotId} applied 0 updates", bot.Id); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Bot auto sync apply failed for bot {BotId}", bot.Id); } } } diff --git a/tools/package-module.ps1 b/tools/package-module.ps1 index ae4b48b..77cc204 100644 --- a/tools/package-module.ps1 +++ b/tools/package-module.ps1 @@ -48,6 +48,7 @@ docker run --rm ` -w "/src" ` mcr.microsoft.com/dotnet/sdk:8.0 ` dotnet publish "$projectContainer" -c Release -o "$publishContainer" /p:UseAppHost=false +if ($LASTEXITCODE -ne 0) { throw "dotnet publish 失败(退出码:$LASTEXITCODE)" } $stagingLib = Join-Path $stagingHost "lib" New-Item -ItemType Directory -Force -Path $stagingLib | Out-Null