diff --git a/src/TelegramPanel.Core/Services/Telegram/BotUpdateHub.cs b/src/TelegramPanel.Core/Services/Telegram/BotUpdateHub.cs index 55c72a5..fea9544 100644 --- a/src/TelegramPanel.Core/Services/Telegram/BotUpdateHub.cs +++ b/src/TelegramPanel.Core/Services/Telegram/BotUpdateHub.cs @@ -26,6 +26,7 @@ public sealed class BotUpdateHub : IAsyncDisposable private readonly SemaphoreSlim _gate = new(1, 1); private readonly Dictionary _pollersByToken = new(StringComparer.Ordinal); + private readonly HashSet _pollingWebhookClearedTokens = new(StringComparer.Ordinal); // Webhook 模式下的接收器(token -> receiver),不启动轮询 private readonly ConcurrentDictionary _webhookReceivers = new(StringComparer.Ordinal); @@ -224,6 +225,10 @@ public sealed class BotUpdateHub : IAsyncDisposable if (string.IsNullOrWhiteSpace(token)) throw new InvalidOperationException("Bot Token 为空"); + // 重要:切回 Long Polling 时,Telegram 侧若残留 webhook,会导致 getUpdates 持续 409。 + // 这里在首次订阅时兜底删一次 webhook(不丢 pending updates),避免“切模式后一直收不到更新”。 + await EnsurePollingModeReadyAsync(token, cancellationToken); + if (!_pollersByToken.TryGetValue(token, out var poller)) { poller = await BotPoller.CreateAsync(botId, token, bot.LastUpdateId, _scopeFactory, _botApi, _logger, cancellationToken); @@ -248,6 +253,7 @@ public sealed class BotUpdateHub : IAsyncDisposable { pollers = _pollersByToken.Values.ToList(); _pollersByToken.Clear(); + _pollingWebhookClearedTokens.Clear(); receivers = _webhookReceivers.Values.ToList(); _webhookReceivers.Clear(); @@ -270,6 +276,33 @@ public sealed class BotUpdateHub : IAsyncDisposable } } + private async Task EnsurePollingModeReadyAsync(string token, CancellationToken cancellationToken) + { + if (_pollingWebhookClearedTokens.Contains(token)) + return; + + try + { + await _botApi.DeleteWebhookAsync(token, dropPendingUpdates: false, cancellationToken); + _pollingWebhookClearedTokens.Add(token); + _logger.LogInformation("Polling mode ensured: webhook deleted for bot token {TokenHint}", MaskToken(token)); + } + catch (Exception ex) + { + // 不中断订阅流程:即使删 webhook 失败,也允许继续尝试轮询; + // 下次订阅会再次尝试,便于网络抖动后的自恢复。 + _logger.LogWarning(ex, "Failed to delete webhook before polling (token={TokenHint}), will retry later", MaskToken(token)); + } + } + + private static string MaskToken(string token) + { + token = (token ?? string.Empty).Trim(); + if (token.Length <= 8) + return "***"; + return $"{token[..4]}...{token[^4..]}"; + } + public sealed class BotUpdateSubscription : IAsyncDisposable { private readonly Func _dispose; diff --git a/src/TelegramPanel.Web/Components/Pages/Bots.razor b/src/TelegramPanel.Web/Components/Pages/Bots.razor index fdf2550..a295bea 100644 --- a/src/TelegramPanel.Web/Components/Pages/Bots.razor +++ b/src/TelegramPanel.Web/Components/Pages/Bots.razor @@ -135,7 +135,11 @@ loading = true; try { - await BotManagement.CreateBotAsync(newName, newToken, newUsername); + var name = (newName ?? string.Empty).Trim(); + var tokenToRegister = (newToken ?? string.Empty).Trim(); + var username = string.IsNullOrWhiteSpace(newUsername) ? null : newUsername.Trim(); + await BotManagement.CreateBotAsync(name, tokenToRegister, username); + await TryRegisterWebhookForBotAsync(tokenToRegister, showSuccessToast: false); newName = ""; newToken = ""; newUsername = ""; @@ -192,6 +196,10 @@ { await BotManagement.SetBotActiveStatusAsync(bot.Id, isActive); bot.IsActive = isActive; + if (isActive) + await TryRegisterWebhookForBotAsync(bot.Token, showSuccessToast: false); + else + await TryDeleteWebhookForBotAsync(bot.Token, showSuccessToast: false); Snackbar.Add(isActive ? "已启用 Bot" : "已停用 Bot", Severity.Success); } catch (Exception ex) @@ -239,7 +247,7 @@ newToken: model.NewToken); if (!string.IsNullOrWhiteSpace(model.NewToken)) - await TryRegisterWebhookForBotAsync(model.NewToken); + await TryRegisterWebhookForBotAsync(model.NewToken, showSuccessToast: true); await LoadBots(); Snackbar.Add("已保存", Severity.Success); @@ -254,7 +262,7 @@ } } - private async Task TryRegisterWebhookForBotAsync(string token) + private async Task TryRegisterWebhookForBotAsync(string token, bool showSuccessToast) { var webhookEnabled = string.Equals( Configuration["Telegram:WebhookEnabled"]?.Trim(), @@ -264,7 +272,11 @@ if (!webhookEnabled) return; - var baseUrl = (Configuration["Telegram:WebhookBaseUrl"] ?? "").Trim(); + var normalizedToken = (token ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(normalizedToken)) + return; + + var baseUrl = (Configuration["Telegram:WebhookBaseUrl"] ?? "").Trim().TrimEnd('/'); var secretToken = (Configuration["Telegram:WebhookSecretToken"] ?? "").Trim(); if (string.IsNullOrWhiteSpace(baseUrl) || string.IsNullOrWhiteSpace(secretToken)) @@ -275,20 +287,47 @@ try { - var pathToken = WebhookTokenHelper.ToWebhookPathToken(token.Trim()); - var webhookUrl = $"{baseUrl.TrimEnd('/')}/api/bot/webhook/{pathToken}"; + var pathToken = WebhookTokenHelper.ToWebhookPathToken(normalizedToken); + var webhookUrl = $"{baseUrl}/api/bot/webhook/{pathToken}"; await BotApi.SetWebhookAsync( - token: token.Trim(), + token: normalizedToken, url: webhookUrl, secretToken: secretToken, allowedUpdates: BotUpdateHub.AllowedUpdatesJson, cancellationToken: CancellationToken.None); - Snackbar.Add("Webhook 已重新注册", Severity.Success); + if (showSuccessToast) + Snackbar.Add("Webhook 已重新注册", Severity.Success); } catch (Exception ex) { Snackbar.Add($"Token 已更换,但重新注册 Webhook 失败:{ex.Message}", Severity.Warning); } } + + private async Task TryDeleteWebhookForBotAsync(string token, bool showSuccessToast) + { + var webhookEnabled = string.Equals( + Configuration["Telegram:WebhookEnabled"]?.Trim(), + "true", + StringComparison.OrdinalIgnoreCase); + + if (!webhookEnabled) + return; + + var normalizedToken = (token ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(normalizedToken)) + return; + + try + { + await BotApi.DeleteWebhookAsync(normalizedToken, dropPendingUpdates: false, cancellationToken: CancellationToken.None); + if (showSuccessToast) + Snackbar.Add("Webhook 已删除", Severity.Info); + } + catch (Exception ex) + { + Snackbar.Add($"停用 Bot 后删除 Webhook 失败:{ex.Message}", Severity.Warning); + } + } }