From 2d3e139eb8f46c3af255983e48c8b45e2215b4b0 Mon Sep 17 00:00:00 2001 From: meoacgx Date: Wed, 14 Jan 2026 01:47:43 +0800 Subject: [PATCH 1/8] feat(import): batch actions for imported accounts --- .../Components/Pages/AccountImport.razor | 603 ++++++++++++++++++ 1 file changed, 603 insertions(+) diff --git a/src/TelegramPanel.Web/Components/Pages/AccountImport.razor b/src/TelegramPanel.Web/Components/Pages/AccountImport.razor index 7082651..5af1bcf 100644 --- a/src/TelegramPanel.Web/Components/Pages/AccountImport.razor +++ b/src/TelegramPanel.Web/Components/Pages/AccountImport.razor @@ -1,7 +1,17 @@ @page "/accounts/import" @inject AccountImportService AccountImportService +@inject AccountManagementService AccountManagement +@inject AccountCategoryManagementService CategoryManagement +@inject AccountTelegramToolsService AccountTelegramTools +@inject TelegramPanel.Core.Services.AccountRiskService RiskService @inject IConfiguration Configuration @inject ISnackbar Snackbar +@inject IDialogService DialogService +@using TelegramPanel.Core.Models +@using TelegramPanel.Core.Utils +@using TelegramPanel.Data.Entities +@using TelegramPanel.Web.Components.Dialogs +@implements IDisposable 导入账号 - Telegram Panel @@ -168,6 +178,82 @@ } +@if (importedAccounts.Any()) +{ + + + 已导入账号(可批量操作) + + + 清空 + + + + + + @SelectionToggleText + + + 刷新已选状态(可深度) + + + 踢出其他设备(已选) + + + + 批量加群/订阅(已选) + 批量退群/退订(已选) + 修改二级密码(已选) + 批量换绑邮箱(找回+登录)(Cloud Mail)(已选) + 批量修改分类(已选) + 批量改昵称(已选) + 批量改用户名(已选) + 批量改Bio(已选) + + + @if (selectedAccounts.Count > 0) + { + 已选 @selectedAccounts.Count + } + else + { + 共 @importedAccounts.Count 个账号 + } + + + + + 手机号 + 用户ID + 用户名 + 分类 + + + @context.DisplayPhone + @context.UserId + @(context.Username ?? "-") + + @if (context.Category != null) + { + + @context.Category.Name + + } + else + { + 未分类 + } + + + + + +} + @code { private List selectedFiles = new(); private bool importing = false; @@ -181,6 +267,32 @@ private List importResults = new(); + private bool actionLoading = false; + private bool Busy => importing || importingZip || importingString || actionLoading; + + private readonly HashSet importedPhones = new(StringComparer.Ordinal); + private List importedAccounts = new(); + private HashSet selectedAccounts = new(); + private List categories = new(); + private readonly Dictionary telegramStatus = new(); + private readonly CancellationTokenSource _pageCts = new(); + + public void Dispose() + { + try + { + _pageCts.Cancel(); + } + catch + { + // ignore + } + finally + { + _pageCts.Dispose(); + } + } + private bool TryGetTelegramApi(out int apiId, out string apiHash) { apiHash = Configuration["Telegram:ApiHash"] ?? ""; @@ -215,6 +327,7 @@ var twoFactorPwd = string.IsNullOrWhiteSpace(zipTwoFactorPassword) ? null : zipTwoFactorPassword.Trim(); var results = await AccountImportService.ImportFromZipAsync(zipFile, twoFactorPassword: twoFactorPwd); importResults.AddRange(results); + await MergeImportedFromResultsAsync(results); var successCount = results.Count(r => r.Success); var failureCount = results.Count - successCount; @@ -257,6 +370,7 @@ ); importResults.AddRange(results); + await MergeImportedFromResultsAsync(results); var successCount = results.Count(r => r.Success); var failureCount = results.Count - successCount; @@ -303,6 +417,7 @@ ); importResults.Add(result); + await MergeImportedFromResultsAsync(new[] { result }); if (result.Success) { @@ -323,4 +438,492 @@ importingString = false; } } + + private async Task MergeImportedFromResultsAsync(IEnumerable results) + { + var changed = false; + foreach (var r in results) + { + if (!r.Success) + continue; + if (string.IsNullOrWhiteSpace(r.Phone)) + continue; + + var digits = PhoneNumberFormatter.NormalizeToDigits(r.Phone); + if (string.IsNullOrWhiteSpace(digits)) + continue; + + if (importedPhones.Add(digits)) + changed = true; + } + + if (changed) + await ReloadImportedAccountsAsync(); + } + + private async Task ReloadImportedAccountsAsync() + { + var list = new List(); + foreach (var phone in importedPhones) + { + var a = await AccountManagement.GetAccountByPhoneAsync(phone); + if (a != null) + list.Add(a); + } + + importedAccounts = list + .Where(a => a.Id > 0) + .GroupBy(a => a.Id) + .Select(g => g.First()) + .OrderByDescending(a => a.Id) + .ToList(); + + selectedAccounts.Clear(); + StateHasChanged(); + } + + private Task ClearImportedAccounts() + { + importedPhones.Clear(); + importedAccounts.Clear(); + selectedAccounts.Clear(); + telegramStatus.Clear(); + return Task.CompletedTask; + } + + private Task SelectAllImported() + { + selectedAccounts = importedAccounts.ToHashSet(); + return Task.CompletedTask; + } + + private Task InvertSelection() + { + var targets = importedAccounts.ToList(); + foreach (var a in targets) + { + if (selectedAccounts.Contains(a)) + selectedAccounts.Remove(a); + else + selectedAccounts.Add(a); + } + return Task.CompletedTask; + } + + private Task ClearSelection() + { + selectedAccounts.Clear(); + return Task.CompletedTask; + } + + private SelectionToggleMode _selectionToggleMode = SelectionToggleMode.SelectAll; + private string SelectionToggleText => _selectionToggleMode switch + { + SelectionToggleMode.SelectAll => "全选本页", + SelectionToggleMode.Invert => "反选本页", + SelectionToggleMode.Clear => "清空选择", + _ => "全选本页" + }; + + private string SelectionToggleIcon => _selectionToggleMode switch + { + SelectionToggleMode.SelectAll => Icons.Material.Filled.SelectAll, + SelectionToggleMode.Invert => Icons.Material.Filled.Flip, + SelectionToggleMode.Clear => Icons.Material.Filled.Clear, + _ => Icons.Material.Filled.SelectAll + }; + + private async Task CycleSelectionToggle() + { + switch (_selectionToggleMode) + { + case SelectionToggleMode.SelectAll: + await SelectAllImported(); + _selectionToggleMode = SelectionToggleMode.Invert; + break; + case SelectionToggleMode.Invert: + await InvertSelection(); + _selectionToggleMode = SelectionToggleMode.Clear; + break; + case SelectionToggleMode.Clear: + await ClearSelection(); + _selectionToggleMode = SelectionToggleMode.SelectAll; + break; + default: + _selectionToggleMode = SelectionToggleMode.SelectAll; + break; + } + } + + private enum SelectionToggleMode + { + SelectAll = 0, + Invert = 1, + Clear = 2 + } + + private async Task LoadCategories() + { + categories = (await CategoryManagement.GetAllCategoriesAsync()).ToList(); + } + + private async Task OpenBatchChangeTwoFactorPassword() + { + if (Busy) + return; + + var ids = selectedAccounts.Select(a => a.Id).Distinct().ToList(); + if (ids.Count == 0) + { + Snackbar.Add("请先选择账号", Severity.Info); + return; + } + + var parameters = new DialogParameters + { + ["AccountIds"] = ids + }; + var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Small, FullWidth = true }; + DialogService.Show("批量修改二级密码", parameters, options); + await Task.CompletedTask; + } + + private async Task OpenBatchChangeTwoFactorRecoveryEmail() + { + if (Busy) + return; + + var ids = selectedAccounts.Select(a => a.Id).Distinct().ToList(); + if (ids.Count == 0) + { + Snackbar.Add("请先选择账号", Severity.Info); + return; + } + + var parameters = new DialogParameters + { + ["AccountIds"] = ids + }; + var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Small, FullWidth = true }; + DialogService.Show("批量换绑邮箱(找回+登录)", parameters, options); + await Task.CompletedTask; + } + + private async Task OpenBatchSetAccountCategory() + { + if (Busy) + return; + + var targets = selectedAccounts.Select(a => a.Id).Distinct().ToList(); + if (targets.Count == 0) + { + Snackbar.Add("请先选择账号", Severity.Info); + return; + } + + if (categories.Count == 0) + await LoadCategories(); + + var parameters = new DialogParameters + { + ["Categories"] = categories, + ["SelectedCount"] = targets.Count + }; + var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Small, FullWidth = true }; + var dialog = DialogService.Show("批量修改分类", parameters, options)!; + var result = await dialog.Result; + if (result is not { Canceled: false }) + return; + + var categoryIdRaw = result.Data is int x ? x : 0; + int? categoryId = categoryIdRaw <= 0 ? null : categoryIdRaw; + + actionLoading = true; + try + { + foreach (var id in targets) + await AccountManagement.UpdateAccountCategoryAsync(id, categoryId); + + await ReloadImportedAccountsAsync(); + Snackbar.Add($"分类已更新:{targets.Count} 个账号", Severity.Success); + } + catch (Exception ex) + { + Snackbar.Add($"修改分类失败:{ex.Message}", Severity.Error); + } + finally + { + actionLoading = false; + } + } + + private async Task OpenBatchChangeNickname() + { + if (Busy) + return; + + var ids = selectedAccounts.Select(a => a.Id).Distinct().ToList(); + if (ids.Count == 0) + { + Snackbar.Add("请先选择账号", Severity.Info); + return; + } + + var parameters = new DialogParameters + { + ["AccountIds"] = ids + }; + var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Medium, FullWidth = true }; + var dialog = DialogService.Show("批量修改昵称", parameters, options)!; + var result = await dialog.Result; + if (result is { Canceled: false }) + await ReloadImportedAccountsAsync(); + } + + private async Task OpenBatchChangeUsername() + { + if (Busy) + return; + + var ids = selectedAccounts.Select(a => a.Id).Distinct().ToList(); + if (ids.Count == 0) + { + Snackbar.Add("请先选择账号", Severity.Info); + return; + } + + var parameters = new DialogParameters + { + ["AccountIds"] = ids + }; + var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Medium, FullWidth = true }; + var dialog = DialogService.Show("批量修改用户名", parameters, options)!; + var result = await dialog.Result; + if (result is { Canceled: false }) + await ReloadImportedAccountsAsync(); + } + + private async Task OpenBatchChangeBio() + { + if (Busy) + return; + + var ids = selectedAccounts.Select(a => a.Id).Distinct().ToList(); + if (ids.Count == 0) + { + Snackbar.Add("请先选择账号", Severity.Info); + return; + } + + var parameters = new DialogParameters + { + ["AccountIds"] = ids + }; + var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Medium, FullWidth = true }; + var dialog = DialogService.Show("批量修改 Bio", parameters, options)!; + var result = await dialog.Result; + if (result is { Canceled: false }) + await ReloadImportedAccountsAsync(); + } + + private async Task OpenBatchJoinSubscribe() + { + if (Busy) + return; + + var ids = selectedAccounts.Select(a => a.Id).Distinct().ToList(); + if (ids.Count == 0) + { + Snackbar.Add("请先选择账号", Severity.Info); + return; + } + + var parameters = new DialogParameters + { + ["AccountIds"] = ids, + ["Operation"] = "join" + }; + var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Medium, FullWidth = true }; + DialogService.Show("批量加群/订阅", parameters, options); + await Task.CompletedTask; + } + + private async Task OpenBatchLeaveUnsubscribe() + { + if (Busy) + return; + + var ids = selectedAccounts.Select(a => a.Id).Distinct().ToList(); + if (ids.Count == 0) + { + Snackbar.Add("请先选择账号", Severity.Info); + return; + } + + var parameters = new DialogParameters + { + ["AccountIds"] = ids, + ["Operation"] = "leave" + }; + var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Medium, FullWidth = true }; + DialogService.Show("批量退群/退订", parameters, options); + await Task.CompletedTask; + } + + private async Task BatchRefreshTelegramStatus() + { + var targets = selectedAccounts + .Where(a => a.Id > 0) + .GroupBy(a => a.Id) + .Select(g => g.First()) + .ToList(); + + if (targets.Count == 0) + { + Snackbar.Add("请先选择账号", Severity.Warning); + return; + } + + actionLoading = true; + try + { + bool? probeResult = await DialogService.ShowMessageBox( + "刷新已选 Telegram 状态", + $"将刷新已选 {targets.Count} 个账号的 Telegram 状态。\n\n是否进行深度探测(将对每个账号创建并删除一个测试频道,用于判断【创建频道接口是否被冻结】)?", + yesText: "深度探测", + cancelText: "普通刷新"); + + var probe = probeResult == true; + + // 批量风控检查:深度探测是敏感操作 + if (probe && targets.Count > 0) + { + var batchCheck = RiskService.CheckBatchAccounts(targets); + if (batchCheck.HasRiskyAccounts) + { + var parameters = new DialogParameters + { + ["Message"] = $"检测到 {batchCheck.RiskyCount} 个风险账号", + ["DetailedMessage"] = batchCheck.GetRiskySummary(), + ["ShowRecommendations"] = true, + ["ShowExcludeOption"] = true, + ["RiskyAccounts"] = batchCheck.RiskyAccounts.ToList() + }; + var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Large }; + var dialog = DialogService.Show("批量操作风控警告", parameters, options); + var dialogResult = await dialog.Result; + + if (dialogResult.Canceled) + { + actionLoading = false; + return; + } + + if (dialogResult.Data is TelegramPanel.Core.Services.RiskWarningAction action + && action == TelegramPanel.Core.Services.RiskWarningAction.ExcludeRisky) + { + targets = batchCheck.SafeAccounts.ToList(); + if (targets.Count == 0) + { + Snackbar.Add("排除风险账号后无剩余账号", Severity.Info); + actionLoading = false; + return; + } + } + } + } + + foreach (var a in targets) + { + try + { + var result = await AccountTelegramTools.RefreshAccountStatusAsync(a.Id, probeCreateChannel: probe, cancellationToken: _pageCts.Token); + telegramStatus[a.Id] = result; + } + catch (Exception ex) + { + telegramStatus[a.Id] = new TelegramAccountStatusResult( + Ok: false, + Summary: "刷新失败", + Details: ex.Message, + CheckedAtUtc: DateTime.UtcNow); + } + + StateHasChanged(); + } + + await ReloadImportedAccountsAsync(); + Snackbar.Add($"批量刷新完成:{targets.Count} 个账号", Severity.Success); + } + catch (Exception ex) + { + Snackbar.Add($"批量刷新失败:{ex.Message}", Severity.Error); + } + finally + { + actionLoading = false; + } + } + + private async Task BatchKickAllOtherDevices() + { + if (actionLoading) + return; + + var targets = selectedAccounts.ToList(); + if (targets.Count == 0) + { + Snackbar.Add("请先选择账号", Severity.Info); + return; + } + + bool? result = await DialogService.ShowMessageBox( + "确认踢出", + $"将对 {targets.Count} 个账号执行【踢出所有其他设备】(会保留面板当前会话)。是否继续?", + yesText: "继续", cancelText: "取消"); + + if (result != true) + return; + + actionLoading = true; + try + { + var success = 0; + var failures = new List(); + + foreach (var a in targets) + { + try + { + var ok = await AccountTelegramTools.KickAllOtherAuthorizationsAsync(a.Id); + if (ok) success++; + else failures.Add($"{a.DisplayPhone}:返回 false"); + } + catch (Exception ex) + { + failures.Add($"{a.DisplayPhone}:{ex.Message}"); + } + } + + selectedAccounts.Clear(); + + if (failures.Count == 0) + { + Snackbar.Add($"踢出完成:成功 {success}/{targets.Count}", Severity.Success); + return; + } + + Snackbar.Add($"踢出完成:成功 {success}/{targets.Count},失败 {failures.Count}", Severity.Warning); + + var details = string.Join(Environment.NewLine, failures.Take(80)); + if (failures.Count > 80) + details += Environment.NewLine + $"... 仅展示前 80 条(共 {failures.Count} 条)"; + + await DialogService.ShowMessageBox("失败明细(前 80 条)", details, "关闭"); + } + finally + { + actionLoading = false; + } + } } From 168b159af5773475013f24752bddc8751955f7ac Mon Sep 17 00:00:00 2001 From: meoacgx Date: Wed, 14 Jan 2026 02:13:13 +0800 Subject: [PATCH 2/8] docs: add roadmap --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index b061af1..34920b5 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,14 @@ docker compose up -d --build --- +## Roadmap(计划) + +- 批量清空联系人、批量退出所有频道和群组 +- 批量手机验证码重新登录,用于刷新会话 session +- 手机号注册:未注册号支持完整注册流程(姓名/可选邮箱/邮箱验证码等) +- 注册通用接码 API:抽象接口 + 主程序只依赖抽象 +- 对接不同 API 厂商:通过“厂商适配模块”实现,无需改动主程序代码 + ## ⭐ Star History [![Star History Chart](https://api.star-history.com/svg?repos=moeacgx/Telegram-Panel&type=Date)](https://star-history.com/#moeacgx/Telegram-Panel&Date) From 5a6599af035681dc475fd89e8dedc1a911460b36 Mon Sep 17 00:00:00 2001 From: meoacgx Date: Wed, 14 Jan 2026 02:17:26 +0800 Subject: [PATCH 3/8] docs: unify todo list --- README.md | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 34920b5..16b81ae 100644 --- a/README.md +++ b/README.md @@ -42,10 +42,13 @@ - [ ] 一键退群/退订、订阅(频道/群组) - [ ] 一键清空联系人 - [ ] 批量手机号验证码重新登录(用于刷新会话 session) +- [ ] 手机号注册:未注册号支持完整注册流程(姓名/可选邮箱/邮箱验证码等) +- [ ] 通用接码 API:抽象接口 + 主程序只依赖抽象;厂商通过“适配模块”对接(无需改动主程序代码) - [ ] 支持更换手机号 - [ ] 多代理:支持账号分类绑定代理 - [ ] 多 API:支持账号分类绑定 ApiId/ApiHash - [ ] 定时创建频道、定时公开频道 +- [ ] 定时刷粉丝:对接刷粉 API(通用适配结构),通过适配模块对接多家刷粉平台 - [ ] 群聊定时发言养号 ## 快速开始 @@ -85,16 +88,6 @@ docker compose up -d --build |---|---|---| | | | | ---- - -## Roadmap(计划) - -- 批量清空联系人、批量退出所有频道和群组 -- 批量手机验证码重新登录,用于刷新会话 session -- 手机号注册:未注册号支持完整注册流程(姓名/可选邮箱/邮箱验证码等) -- 注册通用接码 API:抽象接口 + 主程序只依赖抽象 -- 对接不同 API 厂商:通过“厂商适配模块”实现,无需改动主程序代码 - ## ⭐ Star History [![Star History Chart](https://api.star-history.com/svg?repos=moeacgx/Telegram-Panel&type=Date)](https://star-history.com/#moeacgx/Telegram-Panel&Date) From 0401312ee37647996637d2ae747b63b83a6e5467 Mon Sep 17 00:00:00 2001 From: meoacgx Date: Mon, 19 Jan 2026 00:22:36 +0800 Subject: [PATCH 4/8] fix(logging,webhook): keep error logs; hash webhook path token --- .../Services/Telegram/BotUpdateHub.cs | 86 ++++++++++++++++- .../Services/Telegram/WebhookTokenHelper.cs | 48 ++++++++++ .../Components/Pages/Bots.razor | 3 +- .../Components/Pages/Settings.razor | 2 +- src/TelegramPanel.Web/Program.cs | 93 ++++++++++++++----- .../Services/WebhookRegistrationService.cs | 7 +- 6 files changed, 206 insertions(+), 33 deletions(-) create mode 100644 src/TelegramPanel.Core/Services/Telegram/WebhookTokenHelper.cs diff --git a/src/TelegramPanel.Core/Services/Telegram/BotUpdateHub.cs b/src/TelegramPanel.Core/Services/Telegram/BotUpdateHub.cs index 66d8d65..55c72a5 100644 --- a/src/TelegramPanel.Core/Services/Telegram/BotUpdateHub.cs +++ b/src/TelegramPanel.Core/Services/Telegram/BotUpdateHub.cs @@ -28,7 +28,12 @@ public sealed class BotUpdateHub : IAsyncDisposable private readonly Dictionary _pollersByToken = new(StringComparer.Ordinal); // Webhook 模式下的接收器(token -> receiver),不启动轮询 - private readonly Dictionary _webhookReceivers = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary _webhookReceivers = new(StringComparer.Ordinal); + + private static readonly TimeSpan WebhookTokenCacheTtl = TimeSpan.FromMinutes(5); + private readonly SemaphoreSlim _webhookTokenCacheGate = new(1, 1); + private DateTimeOffset _webhookTokenCacheBuiltAtUtc = DateTimeOffset.MinValue; + private Dictionary _botTokenByWebhookPathToken = new(StringComparer.Ordinal); public BotUpdateHub( IServiceScopeFactory scopeFactory, @@ -57,10 +62,19 @@ public sealed class BotUpdateHub : IAsyncDisposable if (string.IsNullOrWhiteSpace(token)) return false; + if (_webhookReceivers.TryGetValue(token, out var fastReceiver)) + { + fastReceiver.Inject(update); + return true; + } + + BotWebhookReceiver receiver; + + // 仅在“首次遇到某个 token”时加全局锁,避免高频 webhook 进入串行瓶颈。 await _gate.WaitAsync(cancellationToken); try { - if (!_webhookReceivers.TryGetValue(token, out var receiver)) + if (!_webhookReceivers.TryGetValue(token, out receiver!)) { // 验证 token 有效性并创建 receiver using var scope = _scopeFactory.CreateScope(); @@ -72,14 +86,76 @@ public sealed class BotUpdateHub : IAsyncDisposable receiver = new BotWebhookReceiver(bot.Id, token, _scopeFactory, _logger); _webhookReceivers[token] = receiver; } - - receiver.Inject(update); - return true; } finally { _gate.Release(); } + + receiver.Inject(update); + return true; + } + + /// + /// 将 Webhook 路径中的 token(推荐为 SHA256(token))解析为真实的 bot token。 + /// + public async Task ResolveBotTokenFromWebhookPathAsync(string pathToken, CancellationToken cancellationToken) + { + pathToken = (pathToken ?? "").Trim(); + if (string.IsNullOrWhiteSpace(pathToken)) + return null; + + // 兼容:若仍使用明文 bot token 作为路径(不推荐),直接返回。 + if (WebhookTokenHelper.IsLikelyPlainBotToken(pathToken)) + return pathToken; + + if (!WebhookTokenHelper.IsSha256Hex(pathToken)) + return null; + + var builtAt = _webhookTokenCacheBuiltAtUtc; + if (builtAt != DateTimeOffset.MinValue + && DateTimeOffset.UtcNow - builtAt < WebhookTokenCacheTtl + && _botTokenByWebhookPathToken.TryGetValue(pathToken, out var cached)) + { + return cached; + } + + await _webhookTokenCacheGate.WaitAsync(cancellationToken); + try + { + builtAt = _webhookTokenCacheBuiltAtUtc; + if (builtAt == DateTimeOffset.MinValue || DateTimeOffset.UtcNow - builtAt >= WebhookTokenCacheTtl) + await RebuildWebhookTokenCacheAsync(cancellationToken); + + return _botTokenByWebhookPathToken.TryGetValue(pathToken, out var token) ? token : null; + } + finally + { + _webhookTokenCacheGate.Release(); + } + } + + private async Task RebuildWebhookTokenCacheAsync(CancellationToken cancellationToken) + { + using var scope = _scopeFactory.CreateScope(); + var botRepo = scope.ServiceProvider.GetRequiredService(); + var bots = await botRepo.GetAllAsync(); + + var map = new Dictionary(StringComparer.Ordinal); + foreach (var bot in bots) + { + if (!bot.IsActive || string.IsNullOrWhiteSpace(bot.Token)) + continue; + + var token = bot.Token.Trim(); + if (string.IsNullOrWhiteSpace(token)) + continue; + + map[WebhookTokenHelper.ToWebhookPathToken(token)] = token; + } + + _botTokenByWebhookPathToken = map; + _webhookTokenCacheBuiltAtUtc = DateTimeOffset.UtcNow; } /// diff --git a/src/TelegramPanel.Core/Services/Telegram/WebhookTokenHelper.cs b/src/TelegramPanel.Core/Services/Telegram/WebhookTokenHelper.cs new file mode 100644 index 0000000..7eae1e0 --- /dev/null +++ b/src/TelegramPanel.Core/Services/Telegram/WebhookTokenHelper.cs @@ -0,0 +1,48 @@ +using System.Security.Cryptography; +using System.Text; + +namespace TelegramPanel.Core.Services.Telegram; + +public static class WebhookTokenHelper +{ + public static string ToWebhookPathToken(string botToken) + { + botToken = (botToken ?? "").Trim(); + if (string.IsNullOrWhiteSpace(botToken)) + throw new ArgumentException("botToken 不能为空", nameof(botToken)); + + var bytes = Encoding.UTF8.GetBytes(botToken); + var hash = SHA256.HashData(bytes); + return Convert.ToHexString(hash).ToLowerInvariant(); + } + + public static bool IsLikelyPlainBotToken(string value) + { + value = (value ?? "").Trim(); + if (value.Length == 0) + return false; + + // Telegram bot token 典型格式:{bot_id}:{token} + return value.Contains(':', StringComparison.Ordinal); + } + + public static bool IsSha256Hex(string value) + { + value = (value ?? "").Trim(); + if (value.Length != 64) + return false; + + foreach (var ch in value) + { + var isHex = + (ch >= '0' && ch <= '9') || + (ch >= 'a' && ch <= 'f') || + (ch >= 'A' && ch <= 'F'); + if (!isHex) + return false; + } + + return true; + } +} + diff --git a/src/TelegramPanel.Web/Components/Pages/Bots.razor b/src/TelegramPanel.Web/Components/Pages/Bots.razor index 85ab4b5..fdf2550 100644 --- a/src/TelegramPanel.Web/Components/Pages/Bots.razor +++ b/src/TelegramPanel.Web/Components/Pages/Bots.razor @@ -275,7 +275,8 @@ try { - var webhookUrl = $"{baseUrl.TrimEnd('/')}/api/bot/webhook/{Uri.EscapeDataString(token.Trim())}"; + var pathToken = WebhookTokenHelper.ToWebhookPathToken(token.Trim()); + var webhookUrl = $"{baseUrl.TrimEnd('/')}/api/bot/webhook/{pathToken}"; await BotApi.SetWebhookAsync( token: token.Trim(), url: webhookUrl, diff --git a/src/TelegramPanel.Web/Components/Pages/Settings.razor b/src/TelegramPanel.Web/Components/Pages/Settings.razor index ddfe4e7..2aa8400 100644 --- a/src/TelegramPanel.Web/Components/Pages/Settings.razor +++ b/src/TelegramPanel.Web/Components/Pages/Settings.razor @@ -209,7 +209,7 @@ 启用日志输出 - 说明:关闭后会禁用 Serilog 输出(控制台/文件)。Docker 仍可能记录 stdout/stderr,建议同时在 docker-compose 配置日志滚动。 + 说明:关闭后仅输出 Error/Fatal(控制台),并停止写入日志文件。Docker 仍可能记录 stdout/stderr,建议同时在 docker-compose 配置日志滚动。 +{ + try + { + if (e.ExceptionObject is Exception ex) + Log.Fatal(ex, "Unhandled exception (IsTerminating={IsTerminating})", e.IsTerminating); + else + Log.Fatal("Unhandled exception: {ExceptionObject} (IsTerminating={IsTerminating})", e.ExceptionObject, e.IsTerminating); + } + catch + { + // ignore + } +}; + +TaskScheduler.UnobservedTaskException += (_, e) => +{ + try + { + Log.Error(e.Exception, "Unobserved task exception"); + } + catch + { + // ignore + } + finally + { + e.SetObserved(); + } +}; + // Add services to the container. builder.Services.AddRazorComponents() .AddInteractiveServerComponents(); @@ -670,7 +705,8 @@ app.UseStaticFiles(); app.UseAntiforgery(); // Serilog 请求日志 -app.UseSerilogRequestLogging(); +if (serilogEnabled) + app.UseSerilogRequestLogging(); app.UseAuthentication(); app.UseAuthorization(); @@ -969,11 +1005,6 @@ app.MapPost("/api/bot/webhook/{secretToken}", async ( } } - // 从路径中的 secretToken 提取 bot token - // 我们使用 SHA256(bot_token) 作为 URL 中的 secret,避免暴露真实 token - // 但更简单的方式是直接用 bot token(Telegram 官方也是这么做的) - // 这里我们假设 secretToken 就是 bot token 的一部分(安全的做法是用 hash) - // 读取请求体 using var reader = new System.IO.StreamReader(http.Request.Body); var body = await reader.ReadToEndAsync(cancellationToken); @@ -989,10 +1020,16 @@ app.MapPost("/api/bot/webhook/{secretToken}", async ( using var doc = System.Text.Json.JsonDocument.Parse(body); var update = doc.RootElement; - // 尝试从数据库查找匹配的 bot token - // 由于 URL 中不应该暴露完整 token,我们需要一个映射机制 - // 这里简化处理:直接用 secretToken 作为查找键 - var success = await updateHub.InjectWebhookUpdateAsync(secretToken, update.Clone(), cancellationToken); + var botToken = await updateHub.ResolveBotTokenFromWebhookPathAsync(secretToken, cancellationToken); + if (string.IsNullOrWhiteSpace(botToken)) + { + logger.LogWarning("Webhook update rejected: unknown or inactive bot"); + return Results.NotFound(); + } + + var sw = Stopwatch.StartNew(); + var success = await updateHub.InjectWebhookUpdateAsync(botToken, update.Clone(), cancellationToken); + sw.Stop(); if (!success) { @@ -1000,8 +1037,11 @@ app.MapPost("/api/bot/webhook/{secretToken}", async ( return Results.NotFound(); } - logger.LogDebug("Webhook update processed: update_id={UpdateId}", - update.TryGetProperty("update_id", out var uid) ? uid.GetInt64() : 0); + var updateId = update.TryGetProperty("update_id", out var uid) ? uid.GetInt64() : 0; + if (sw.Elapsed > TimeSpan.FromSeconds(2)) + logger.LogWarning("Webhook update processed slowly: update_id={UpdateId} elapsed_ms={ElapsedMs}", updateId, (long)sw.Elapsed.TotalMilliseconds); + else + logger.LogDebug("Webhook update processed: update_id={UpdateId} elapsed_ms={ElapsedMs}", updateId, (long)sw.Elapsed.TotalMilliseconds); return Results.Ok(); } @@ -1017,4 +1057,11 @@ app.MapPost("/api/bot/webhook/{secretToken}", async ( Log.Information("Telegram Panel started"); -app.Run(); +try +{ + app.Run(); +} +finally +{ + Log.CloseAndFlush(); +} diff --git a/src/TelegramPanel.Web/Services/WebhookRegistrationService.cs b/src/TelegramPanel.Web/Services/WebhookRegistrationService.cs index 382b6e4..50f0708 100644 --- a/src/TelegramPanel.Web/Services/WebhookRegistrationService.cs +++ b/src/TelegramPanel.Web/Services/WebhookRegistrationService.cs @@ -98,9 +98,10 @@ public sealed class WebhookRegistrationService : BackgroundService { var token = bot.Token!.Trim(); - // 构建 Webhook URL:baseUrl + /api/bot/webhook/{token} - // 注意:这里用 bot token 作为 URL 中的 secretToken,Telegram 会在请求头中发送配置的 secretToken - var webhookUrl = $"{baseUrl.TrimEnd('/')}/api/bot/webhook/{Uri.EscapeDataString(token)}"; + // 构建 Webhook URL:baseUrl + /api/bot/webhook/{SHA256(token)} + // 说明:避免在反向代理/access log 中泄露真实 bot token。 + var pathToken = WebhookTokenHelper.ToWebhookPathToken(token); + var webhookUrl = $"{baseUrl.TrimEnd('/')}/api/bot/webhook/{pathToken}"; await botApi.SetWebhookAsync( token: token, From f551da0a8bce1dfc6c45a830a4a0086d6bce1aec Mon Sep 17 00:00:00 2001 From: meoacgx Date: Thu, 22 Jan 2026 04:06:21 +0800 Subject: [PATCH 5/8] feat(kick): add module page and queue /api/kick --- .../Services/Telegram/BotTelegramService.cs | 9 +- .../Components/ModulePages/KickApiPage.razor | 196 ++++++++++++++ .../ExternalApi/ExternalApiKickTaskHandler.cs | 244 ++++++++++++++++++ src/TelegramPanel.Web/ExternalApi/KickApi.cs | 164 +----------- .../ExternalApi/KickModels.cs | 62 +++++ .../Modules/BuiltIn/KickApiModule.cs | 24 +- src/TelegramPanel.Web/Program.cs | 8 +- 7 files changed, 551 insertions(+), 156 deletions(-) create mode 100644 src/TelegramPanel.Web/Components/ModulePages/KickApiPage.razor create mode 100644 src/TelegramPanel.Web/ExternalApi/ExternalApiKickTaskHandler.cs create mode 100644 src/TelegramPanel.Web/ExternalApi/KickModels.cs diff --git a/src/TelegramPanel.Core/Services/Telegram/BotTelegramService.cs b/src/TelegramPanel.Core/Services/Telegram/BotTelegramService.cs index 4236c1b..9dd86af 100644 --- a/src/TelegramPanel.Core/Services/Telegram/BotTelegramService.cs +++ b/src/TelegramPanel.Core/Services/Telegram/BotTelegramService.cs @@ -824,7 +824,8 @@ public sealed record BotAdminRights( IReadOnlyList channelTelegramIds, long userId, bool permanentBan, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + Func? perChatCallback = null) { var bot = await _botManagement.GetBotAsync(botId) ?? throw new InvalidOperationException($"机器人不存在:{botId}"); @@ -936,6 +937,12 @@ public sealed record BotAdminRights( } } + if (perChatCallback != null) + { + failures.TryGetValue(chatId, out var err); + await perChatCallback(chatId, err, ok, failures.Count); + } + // 降速:避免 Bot API 429 if (delayMs > 0 && index < distinctIds.Count - 1) { diff --git a/src/TelegramPanel.Web/Components/ModulePages/KickApiPage.razor b/src/TelegramPanel.Web/Components/ModulePages/KickApiPage.razor new file mode 100644 index 0000000..cbc38ef --- /dev/null +++ b/src/TelegramPanel.Web/Components/ModulePages/KickApiPage.razor @@ -0,0 +1,196 @@ +@namespace TelegramPanel.Web.Components.ModulePages +@inject TelegramPanel.Core.Services.BotManagementService BotManagement +@inject TelegramPanel.Core.Services.BatchTaskManagementService TaskManagement +@inject ISnackbar Snackbar +@inject NavigationManager Navigation +@using System.Text.Json +@using TelegramPanel.Core.BatchTasks +@using TelegramPanel.Data.Entities +@using TelegramPanel.Web.ExternalApi +@using Microsoft.AspNetCore.Components + +踢人/封禁 + + + + + 提交后会在后台执行,可到【任务中心】查看进度与结果。频道数量较多时可能需要较长时间。 + + + + 全部机器人(谨慎) + @foreach (var b in bots) + { + @b.Name + } + + + + + + + + + @if (!useAllChats && selectedBotId != 0) + { + + } + + + + 提交任务 + + + 打开任务中心 + + + + + +@code +{ + [Parameter] public string ModuleId { get; set; } = ""; + [Parameter] public string PageKey { get; set; } = ""; + + private bool loading = true; + private List bots = new(); + private int selectedBotId = 0; + private long userId; + private bool permanentBan; + private bool useAllChats = true; + private string chatIdsText = ""; + + private bool CanSubmit => !loading && userId > 0 && (selectedBotId == 0 || bots.Any(x => x.Id == selectedBotId)); + + protected override async Task OnInitializedAsync() + { + try + { + bots = (await BotManagement.GetAllBotsAsync()) + .Where(b => b.IsActive) + .OrderBy(b => b.Id) + .ToList(); + } + catch (Exception ex) + { + Snackbar.Add($"加载机器人失败:{ex.Message}", Severity.Error); + } + finally + { + loading = false; + } + } + + private async Task OnSelectedBotChanged(int id) + { + selectedBotId = id; + if (selectedBotId == 0) + useAllChats = true; + await Task.CompletedTask; + } + + private async Task SubmitAsync() + { + if (!CanSubmit) + return; + + var botId = selectedBotId; + var runUseAllChats = botId == 0 ? true : useAllChats; + var chatIds = runUseAllChats ? new List() : ParseChatIds(chatIdsText); + + loading = true; + try + { + var total = await CountTargetsAsync(botId, runUseAllChats, chatIds); + if (total <= 0) + { + Snackbar.Add("未匹配到任何可操作的频道/群组(请先同步 Bot 频道数据,或检查 chat_id 列表)", Severity.Warning); + return; + } + + var log = new KickTaskLog + { + ApiName = "模块页面", + BotId = botId, + UseAllChats = runUseAllChats, + ChatIds = chatIds.Where(x => x != 0).Distinct().OrderBy(x => x).ToList(), + UserId = userId, + PermanentBan = permanentBan, + RequestedAtUtc = DateTime.UtcNow + }; + + var task = new BatchTask + { + TaskType = BatchTaskTypes.ExternalApiKick, + Total = total, + Completed = 0, + Failed = 0, + Config = JsonSerializer.Serialize(log, new JsonSerializerOptions { WriteIndented = true }) + }; + + var created = await TaskManagement.CreateTaskAsync(task); + Snackbar.Add($"已提交后台任务 #{created.Id}。请到【任务中心】查看进度。", Severity.Success); + Navigation.NavigateTo("/tasks"); + } + catch (Exception ex) + { + Snackbar.Add($"提交失败:{ex.Message}", Severity.Error); + } + finally + { + loading = false; + } + } + + private async Task CountTargetsAsync(int botId, bool runUseAllChats, IReadOnlyCollection chatIds) + { + if (botId > 0) + { + var chats = (await BotManagement.GetChatsAsync(botId)).ToList(); + if (!runUseAllChats && chatIds.Count > 0) + chats = chats.Where(c => chatIds.Contains(c.TelegramId)).ToList(); + return chats.Count; + } + + // botId==0: all bots (force useAllChats) + var botsList = (await BotManagement.GetAllBotsAsync()) + .Where(b => b.IsActive) + .OrderBy(b => b.Id) + .ToList(); + + var total = 0; + foreach (var b in botsList) + { + var chats = await BotManagement.GetChatsAsync(b.Id); + total += chats.Count(); + } + return total; + } + + private static List ParseChatIds(string raw) + { + raw = (raw ?? "").Trim(); + if (raw.Length == 0) + return new List(); + + var parts = raw + .Replace("\r", "\n", StringComparison.Ordinal) + .Split(new[] { ',', ';', ' ', '\n', '\t' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + var ids = new List(); + foreach (var p in parts) + { + if (long.TryParse(p, out var v) && v != 0) + ids.Add(v); + } + return ids; + } +} diff --git a/src/TelegramPanel.Web/ExternalApi/ExternalApiKickTaskHandler.cs b/src/TelegramPanel.Web/ExternalApi/ExternalApiKickTaskHandler.cs new file mode 100644 index 0000000..788140e --- /dev/null +++ b/src/TelegramPanel.Web/ExternalApi/ExternalApiKickTaskHandler.cs @@ -0,0 +1,244 @@ +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using TelegramPanel.Core.BatchTasks; +using TelegramPanel.Core.Services; +using TelegramPanel.Core.Services.Telegram; +using TelegramPanel.Data.Entities; +using TelegramPanel.Modules; + +namespace TelegramPanel.Web.ExternalApi; + +public sealed class ExternalApiKickTaskHandler : IModuleTaskHandler +{ + public string TaskType => BatchTaskTypes.ExternalApiKick; + + public async Task ExecuteAsync(IModuleTaskExecutionHost host, CancellationToken cancellationToken) + { + var logger = host.Services.GetRequiredService>(); + var taskManagement = host.Services.GetRequiredService(); + var botManagement = host.Services.GetRequiredService(); + var botTelegram = host.Services.GetRequiredService(); + + var rawConfig = (host.Config ?? "").Trim(); + if (rawConfig.Length == 0) + throw new InvalidOperationException("任务缺少 Config"); + + KickTaskLog? input; + try + { + input = JsonSerializer.Deserialize(rawConfig); + } + catch (Exception ex) + { + throw new InvalidOperationException($"任务 Config JSON 无效:{ex.Message}"); + } + + if (input == null) + throw new InvalidOperationException("任务 Config JSON 为空"); + + if (input.UserId <= 0) + throw new InvalidOperationException("任务 user_id 无效"); + + input.ChatIds ??= new List(); + + var configuredBotId = input.BotId; + var useAllChats = configuredBotId == 0 ? true : input.UseAllChats; + var chatIdSet = new HashSet(input.ChatIds.Where(x => x != 0)); + + var targets = await ResolveTargetsAsync(botManagement, configuredBotId, useAllChats, chatIdSet, cancellationToken); + if (targets.TotalChats == 0) + throw new InvalidOperationException("未匹配到任何可操作的频道/群组"); + + var results = new List(targets.TotalChats); + var completed = 0; + var failed = 0; + + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var linkedToken = linkedCts.Token; + + try + { + foreach (var group in targets.Groups) + { + linkedToken.ThrowIfCancellationRequested(); + + if (!await host.IsStillRunningAsync(linkedToken)) + { + await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(new KickTaskLog + { + ApiName = input.ApiName ?? "", + BotId = configuredBotId, + UseAllChats = useAllChats, + ChatIds = chatIdSet.OrderBy(x => x).ToList(), + UserId = input.UserId, + PermanentBan = input.PermanentBan, + RequestedAtUtc = input.RequestedAtUtc, + Results = results, + Canceled = true + })); + return; + } + + var baseCompleted = completed; + var baseFailed = failed; + var chatById = group.Chats.ToDictionary(c => c.TelegramId); + var recorded = new HashSet(); + + try + { + _ = await botTelegram.BanChatMemberAsync( + botId: group.Bot.Id, + channelTelegramIds: group.Chats.Select(x => x.TelegramId).ToList(), + userId: input.UserId, + permanentBan: input.PermanentBan, + cancellationToken: linkedToken, + perChatCallback: async (chatId, error, okCount, failCount) => + { + if (!recorded.Add(chatId)) + return; + + var ok = error == null; + results.Add(new KickResultItem( + ChatId: chatId.ToString(), + Title: chatById.TryGetValue(chatId, out var ch) ? ch.Title : chatId.ToString(), + Success: ok, + Error: error)); + + completed = baseCompleted + okCount; + failed = baseFailed + failCount; + await host.UpdateProgressAsync(completed, failed, linkedToken); + + if (!await host.IsStillRunningAsync(linkedToken)) + linkedCts.Cancel(); + }); + } + catch (OperationCanceledException) when (linkedCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested) + { + // 用户主动取消(把任务状态改为非 running) + await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(new KickTaskLog + { + ApiName = input.ApiName ?? "", + BotId = configuredBotId, + UseAllChats = useAllChats, + ChatIds = chatIdSet.OrderBy(x => x).ToList(), + UserId = input.UserId, + PermanentBan = input.PermanentBan, + RequestedAtUtc = input.RequestedAtUtc, + Results = results, + Canceled = true + })); + return; + } + catch (Exception ex) + { + logger.LogWarning(ex, "ExternalApiKick failed on bot {BotId} (chats={Count})", group.Bot.Id, group.Chats.Count); + foreach (var chat in group.Chats) + { + if (!recorded.Add(chat.TelegramId)) + continue; + + results.Add(new KickResultItem( + ChatId: chat.TelegramId.ToString(), + Title: chat.Title, + Success: false, + Error: ex.Message)); + failed++; + } + + await host.UpdateProgressAsync(completed, failed, linkedToken); + } + } + } + catch (OperationCanceledException) when (linkedCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested) + { + await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(new KickTaskLog + { + ApiName = input.ApiName ?? "", + BotId = configuredBotId, + UseAllChats = useAllChats, + ChatIds = chatIdSet.OrderBy(x => x).ToList(), + UserId = input.UserId, + PermanentBan = input.PermanentBan, + RequestedAtUtc = input.RequestedAtUtc, + Results = results, + Canceled = true + })); + return; + } + catch (Exception ex) + { + logger.LogWarning(ex, "ExternalApiKick failed (taskId={TaskId})", host.TaskId); + await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(new KickTaskLog + { + ApiName = input.ApiName ?? "", + BotId = configuredBotId, + UseAllChats = useAllChats, + ChatIds = chatIdSet.OrderBy(x => x).ToList(), + UserId = input.UserId, + PermanentBan = input.PermanentBan, + RequestedAtUtc = input.RequestedAtUtc, + Results = results, + Error = ex.Message + })); + throw; + } + + await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(new KickTaskLog + { + ApiName = input.ApiName ?? "", + BotId = configuredBotId, + UseAllChats = useAllChats, + ChatIds = chatIdSet.OrderBy(x => x).ToList(), + UserId = input.UserId, + PermanentBan = input.PermanentBan, + RequestedAtUtc = input.RequestedAtUtc, + Results = results + })); + } + + private static async Task ResolveTargetsAsync( + BotManagementService botManagement, + int configuredBotId, + bool useAllChats, + HashSet configuredChatIds, + CancellationToken cancellationToken) + { + var bots = (await botManagement.GetAllBotsAsync()) + .Where(b => b.IsActive) + .OrderBy(b => b.Id) + .ToList(); + + if (configuredBotId > 0) + bots = bots.Where(b => b.Id == configuredBotId).ToList(); + + var groups = new List(); + var totalChats = 0; + + foreach (var bot in bots) + { + cancellationToken.ThrowIfCancellationRequested(); + + var chats = (await botManagement.GetChatsAsync(bot.Id)).ToList(); + if (!useAllChats && configuredChatIds.Count > 0) + chats = chats.Where(c => configuredChatIds.Contains(c.TelegramId)).ToList(); + + if (chats.Count == 0) + continue; + + groups.Add(new TargetGroup(bot, chats)); + totalChats += chats.Count; + } + + return new TargetResolution(groups, totalChats); + } + + private static string SerializeIndented(T value) + { + return JsonSerializer.Serialize(value, new JsonSerializerOptions { WriteIndented = true }); + } + + private sealed record TargetGroup(Bot Bot, List Chats); + private sealed record TargetResolution(List Groups, int TotalChats); +} + diff --git a/src/TelegramPanel.Web/ExternalApi/KickApi.cs b/src/TelegramPanel.Web/ExternalApi/KickApi.cs index c43bf51..18bc037 100644 --- a/src/TelegramPanel.Web/ExternalApi/KickApi.cs +++ b/src/TelegramPanel.Web/ExternalApi/KickApi.cs @@ -1,6 +1,5 @@ using System.Security.Cryptography; using System.Text; -using System.Text.Json.Serialization; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; @@ -8,7 +7,6 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using System.Text.Json; using TelegramPanel.Core.Services; -using TelegramPanel.Core.Services.Telegram; using TelegramPanel.Core.BatchTasks; using TelegramPanel.Data.Entities; @@ -27,7 +25,6 @@ public static class KickApi HttpContext http, IConfiguration configuration, BotManagementService botManagement, - BotTelegramService botTelegram, BatchTaskManagementService taskManagement, ILoggerFactory loggerFactory, CancellationToken cancellationToken) @@ -86,10 +83,10 @@ public static class KickApi false, "未配置任何可操作的频道/群组(请先在页面选择机器人与频道/群组)", new KickSummary(0, 0, 0), - Array.Empty())); + Array.Empty())); } - // 记录到任务中心(即使外部调用返回了,也能追溯这次操作) + // 提交到任务中心,由后台执行(避免反代/CF 524 超时) var createdTask = await taskManagement.CreateTaskAsync(new BatchTask { TaskType = BatchTaskTypes.ExternalApiKick, @@ -108,120 +105,17 @@ public static class KickApi }) }); - await taskManagement.StartTaskAsync(createdTask.Id); + logger.LogInformation("Kick task queued: taskId={TaskId} api={ApiName} botId={BotId} total={Total} userId={UserId} permanent={Permanent}", + createdTask.Id, matched.Name ?? "", configuredBotId, targets.TotalChats, request.UserId, permanentBan); - var results = new List(targets.TotalChats); - var completed = 0; - var failedCount = 0; - - try - { - foreach (var group in targets.Groups) - { - cancellationToken.ThrowIfCancellationRequested(); - - try - { - var banResult = await botTelegram.BanChatMemberAsync( - botId: group.Bot.Id, - channelTelegramIds: group.Chats.Select(x => x.TelegramId).ToList(), - userId: request.UserId, - permanentBan: permanentBan, - cancellationToken: cancellationToken); - - var failures = banResult.Failures; - foreach (var chat in group.Chats) - { - failures.TryGetValue(chat.TelegramId, out var err); - var ok = err == null; - results.Add(new KickResultItem( - ChatId: chat.TelegramId.ToString(), - Title: chat.Title, - Success: ok, - Error: err)); - - if (ok) completed++; else failedCount++; - } - } - catch (Exception ex) - { - logger.LogWarning(ex, "Kick API failed on bot {BotId} (chats={Count})", group.Bot.Id, group.Chats.Count); - foreach (var chat in group.Chats) - { - results.Add(new KickResultItem( - ChatId: chat.TelegramId.ToString(), - Title: chat.Title, - Success: false, - Error: ex.Message)); - failedCount++; - } - } - - await taskManagement.UpdateTaskProgressAsync(createdTask.Id, completed, failedCount); - } - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - await taskManagement.UpdateTaskProgressAsync(createdTask.Id, completed, failedCount == 0 ? 1 : failedCount); - await taskManagement.UpdateTaskConfigAsync(createdTask.Id, SerializeIndented(new KickTaskLog - { - ApiName = matched.Name ?? "", - BotId = configuredBotId, - UseAllChats = useAllChats, - ChatIds = configuredChatSet.OrderBy(x => x).ToList(), - UserId = request.UserId, - PermanentBan = permanentBan, - RequestedAtUtc = DateTime.UtcNow, - Results = results, - Canceled = true - })); - await taskManagement.CompleteTaskAsync(createdTask.Id, success: false); - throw; - } - catch (Exception ex) - { - logger.LogWarning(ex, "Kick API failed (taskId={TaskId})", createdTask.Id); - await taskManagement.UpdateTaskProgressAsync(createdTask.Id, completed, failedCount == 0 ? 1 : failedCount); - await taskManagement.UpdateTaskConfigAsync(createdTask.Id, SerializeIndented(new KickTaskLog - { - ApiName = matched.Name ?? "", - BotId = configuredBotId, - UseAllChats = useAllChats, - ChatIds = configuredChatSet.OrderBy(x => x).ToList(), - UserId = request.UserId, - PermanentBan = permanentBan, - RequestedAtUtc = DateTime.UtcNow, - Results = results, - Error = ex.Message - })); - await taskManagement.CompleteTaskAsync(createdTask.Id, success: false); - throw; - } - - var okCount = results.Count(x => x.Success); - var total = results.Count; - var failedTotal = total - okCount; - var actionText = permanentBan ? "Banned" : "Kicked"; - var message = $"{actionText} user {request.UserId} from {okCount}/{total} chats"; - - await taskManagement.UpdateTaskConfigAsync(createdTask.Id, SerializeIndented(new KickTaskLog - { - ApiName = matched.Name ?? "", - BotId = configuredBotId, - UseAllChats = useAllChats, - ChatIds = configuredChatSet.OrderBy(x => x).ToList(), - UserId = request.UserId, - PermanentBan = permanentBan, - RequestedAtUtc = DateTime.UtcNow, - Results = results - })); - await taskManagement.CompleteTaskAsync(createdTask.Id, success: failedTotal == 0); - - return Results.Ok(new KickResponse( - Success: true, - Message: message, - Summary: new KickSummary(total, okCount, failedTotal), - Results: results)); + return Results.Json( + new KickResponse( + Success: true, + Message: $"已提交后台任务 #{createdTask.Id},请到面板【任务中心】查看进度与结果。", + Summary: new KickSummary(targets.TotalChats, 0, 0), + Results: Array.Empty(), + TaskId: createdTask.Id), + statusCode: StatusCodes.Status202Accepted); } private static async Task ResolveTargetsAsync( @@ -280,41 +174,7 @@ public static class KickApi return JsonSerializer.Serialize(value, new JsonSerializerOptions { WriteIndented = true }); } - public sealed record KickRequest( - [property: JsonPropertyName("user_id")] long UserId, - [property: JsonPropertyName("permanent_ban")] bool? PermanentBan = null); - - public sealed record KickResponse( - [property: JsonPropertyName("success")] bool Success, - [property: JsonPropertyName("message")] string Message, - [property: JsonPropertyName("summary")] KickSummary Summary, - [property: JsonPropertyName("results")] IReadOnlyList Results); - - public sealed record KickSummary( - [property: JsonPropertyName("total")] int Total, - [property: JsonPropertyName("success")] int Success, - [property: JsonPropertyName("failed")] int Failed); - - public sealed record KickResultItem( - [property: JsonPropertyName("chat_id")] string ChatId, - [property: JsonPropertyName("title")] string Title, - [property: JsonPropertyName("success")] bool Success, - [property: JsonPropertyName("error")] string? Error); - private sealed record TargetGroup(Bot Bot, List Chats); private sealed record TargetResolution(List Groups, int TotalChats); - private sealed class KickTaskLog - { - public string ApiName { get; set; } = ""; - public int BotId { get; set; } - public bool UseAllChats { get; set; } - public List ChatIds { get; set; } = new(); - public long UserId { get; set; } - public bool PermanentBan { get; set; } - public DateTime RequestedAtUtc { get; set; } - public bool Canceled { get; set; } - public string? Error { get; set; } - public List? Results { get; set; } - } } diff --git a/src/TelegramPanel.Web/ExternalApi/KickModels.cs b/src/TelegramPanel.Web/ExternalApi/KickModels.cs new file mode 100644 index 0000000..3bc7681 --- /dev/null +++ b/src/TelegramPanel.Web/ExternalApi/KickModels.cs @@ -0,0 +1,62 @@ +using System.Text.Json.Serialization; + +namespace TelegramPanel.Web.ExternalApi; + +public sealed record KickRequest( + [property: JsonPropertyName("user_id")] long UserId, + [property: JsonPropertyName("permanent_ban")] bool? PermanentBan = null); + +public sealed record KickResponse( + [property: JsonPropertyName("success")] bool Success, + [property: JsonPropertyName("message")] string Message, + [property: JsonPropertyName("summary")] KickSummary Summary, + [property: JsonPropertyName("results")] IReadOnlyList Results, + [property: JsonPropertyName("task_id")] int? TaskId = null); + +public sealed record KickSummary( + [property: JsonPropertyName("total")] int Total, + [property: JsonPropertyName("success")] int Success, + [property: JsonPropertyName("failed")] int Failed); + +public sealed record KickResultItem( + [property: JsonPropertyName("chat_id")] string ChatId, + [property: JsonPropertyName("title")] string Title, + [property: JsonPropertyName("success")] bool Success, + [property: JsonPropertyName("error")] string? Error); + +public sealed class KickTaskLog +{ + [JsonPropertyName("api_name")] + public string ApiName { get; set; } = ""; + + /// + /// 0=all bots + /// + [JsonPropertyName("bot_id")] + public int BotId { get; set; } + + [JsonPropertyName("use_all_chats")] + public bool UseAllChats { get; set; } + + [JsonPropertyName("chat_ids")] + public List ChatIds { get; set; } = new(); + + [JsonPropertyName("user_id")] + public long UserId { get; set; } + + [JsonPropertyName("permanent_ban")] + public bool PermanentBan { get; set; } + + [JsonPropertyName("requested_at_utc")] + public DateTime RequestedAtUtc { get; set; } + + [JsonPropertyName("canceled")] + public bool Canceled { get; set; } + + [JsonPropertyName("error")] + public string? Error { get; set; } + + [JsonPropertyName("results")] + public List? Results { get; set; } +} + diff --git a/src/TelegramPanel.Web/Modules/BuiltIn/KickApiModule.cs b/src/TelegramPanel.Web/Modules/BuiltIn/KickApiModule.cs index 1bcf14a..c52ea2e 100644 --- a/src/TelegramPanel.Web/Modules/BuiltIn/KickApiModule.cs +++ b/src/TelegramPanel.Web/Modules/BuiltIn/KickApiModule.cs @@ -2,12 +2,14 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using MudBlazor; using TelegramPanel.Modules; +using TelegramPanel.Web.Components.ModulePages; using TelegramPanel.Web.ExternalApi; namespace TelegramPanel.Web.Modules.BuiltIn; -public sealed class KickApiModule : ITelegramPanelModule, IModuleApiProvider +public sealed class KickApiModule : ITelegramPanelModule, IModuleApiProvider, IModuleUiProvider { public KickApiModule(string version) { @@ -25,7 +27,7 @@ public sealed class KickApiModule : ITelegramPanelModule, IModuleApiProvider public void ConfigureServices(IServiceCollection services, ModuleHostContext context) { - // built-in:无需额外注入 + services.AddScoped(); } public void MapEndpoints(IEndpointRouteBuilder endpoints, ModuleHostContext context) @@ -49,4 +51,22 @@ public sealed class KickApiModule : ITelegramPanelModule, IModuleApiProvider Order = 10 }; } + + public IEnumerable GetNavItems(ModuleHostContext context) + { + yield break; + } + + public IEnumerable GetPages(ModuleHostContext context) + { + yield return new ModulePageDefinition + { + Key = "kick", + Title = "踢人/封禁", + Icon = Icons.Material.Filled.PersonRemove, + Group = "外部 API", + Order = 10, + ComponentType = typeof(KickApiPage).AssemblyQualifiedName ?? "" + }; + } } diff --git a/src/TelegramPanel.Web/Program.cs b/src/TelegramPanel.Web/Program.cs index 5743969..81187ba 100644 --- a/src/TelegramPanel.Web/Program.cs +++ b/src/TelegramPanel.Web/Program.cs @@ -262,7 +262,13 @@ if (serilogEnabled) { loggerConfig = loggerConfig .ReadFrom.Configuration(builder.Configuration) - .WriteTo.File("logs/telegram-panel-.log", rollingInterval: RollingInterval.Day, retainedFileCountLimit: retainedFileCountLimit); + // buffered=true:降低磁盘抖动/IO 阻塞导致的请求卡顿风险(尤其是低配 VPS/挂载卷/杀软扫描场景) + .WriteTo.File( + "logs/telegram-panel-.log", + rollingInterval: RollingInterval.Day, + retainedFileCountLimit: retainedFileCountLimit, + buffered: true, + flushToDiskInterval: TimeSpan.FromSeconds(1)); } else { From e420e8e8b5e60c37fc21b41421dbfb2582a92834 Mon Sep 17 00:00:00 2001 From: meoacgx Date: Thu, 22 Jan 2026 04:58:38 +0800 Subject: [PATCH 6/8] feat(kick): filter by bot channel categories --- .../Components/ModulePages/KickApiPage.razor | 173 ++++++++++++++---- 1 file changed, 141 insertions(+), 32 deletions(-) diff --git a/src/TelegramPanel.Web/Components/ModulePages/KickApiPage.razor b/src/TelegramPanel.Web/Components/ModulePages/KickApiPage.razor index cbc38ef..5b19542 100644 --- a/src/TelegramPanel.Web/Components/ModulePages/KickApiPage.razor +++ b/src/TelegramPanel.Web/Components/ModulePages/KickApiPage.razor @@ -32,11 +32,41 @@ - + - @if (!useAllChats && selectedBotId != 0) + @if (selectedBotId != 0 && !useAllChats) { + + 选择分类:@selectedCategories.Count / @categories.Count + + + 全选 + + + 清空 + + + + + + 分类 + 说明 + + + @context.Name + @(!string.IsNullOrWhiteSpace(context.Description) ? context.Description : "-") + + + 暂无分类 + + + + + @@ -62,13 +92,25 @@ private bool loading = true; private List bots = new(); + private List categories = new(); private int selectedBotId = 0; private long userId; private bool permanentBan; private bool useAllChats = true; + private HashSet selectedCategories = new(); + private bool includeUncategorized; private string chatIdsText = ""; - private bool CanSubmit => !loading && userId > 0 && (selectedBotId == 0 || bots.Any(x => x.Id == selectedBotId)); + private bool CanSubmit + => !loading + && userId > 0 + && (selectedBotId == 0 || bots.Any(x => x.Id == selectedBotId)) + && ( + selectedBotId == 0 + || useAllChats + || includeUncategorized + || selectedCategories.Count > 0 + || !string.IsNullOrWhiteSpace(chatIdsText)); protected override async Task OnInitializedAsync() { @@ -78,6 +120,17 @@ .Where(b => b.IsActive) .OrderBy(b => b.Id) .ToList(); + + categories = (await BotManagement.GetCategoriesAsync()) + .OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + + // 默认不对“全部机器人/全部频道”开火 + if (bots.Count > 0) + { + selectedBotId = bots[0].Id; + useAllChats = false; + } } catch (Exception ex) { @@ -91,9 +144,40 @@ private async Task OnSelectedBotChanged(int id) { + var previous = selectedBotId; selectedBotId = id; if (selectedBotId == 0) useAllChats = true; + else if (previous == 0) + useAllChats = false; + + selectedCategories = new HashSet(); + includeUncategorized = false; + chatIdsText = ""; + await Task.CompletedTask; + } + + private async Task OnUseAllChatsChanged() + { + if (useAllChats) + { + selectedCategories = new HashSet(); + includeUncategorized = false; + chatIdsText = ""; + } + await Task.CompletedTask; + } + + private async Task SelectAllCategories() + { + selectedCategories = categories.ToHashSet(); + await Task.CompletedTask; + } + + private async Task ClearCategories() + { + selectedCategories = new HashSet(); + includeUncategorized = false; await Task.CompletedTask; } @@ -104,12 +188,62 @@ var botId = selectedBotId; var runUseAllChats = botId == 0 ? true : useAllChats; - var chatIds = runUseAllChats ? new List() : ParseChatIds(chatIdsText); loading = true; try { - var total = await CountTargetsAsync(botId, runUseAllChats, chatIds); + var total = 0; + var targetChatIds = new List(); + + if (botId == 0) + { + foreach (var b in bots) + total += (await BotManagement.GetChatsAsync(b.Id)).Count(); + } + else + { + var chats = (await BotManagement.GetChatsAsync(botId)).ToList(); + if (runUseAllChats) + { + total = chats.Count; + } + else + { + var selectedIds = new HashSet(); + var selectedCategoryIdSet = selectedCategories.Select(x => x.Id).ToHashSet(); + + if (selectedCategoryIdSet.Count > 0 || includeUncategorized) + { + foreach (var c in chats) + { + if (c.CategoryId.HasValue) + { + if (selectedCategoryIdSet.Contains(c.CategoryId.Value)) + selectedIds.Add(c.TelegramId); + } + else + { + if (includeUncategorized) + selectedIds.Add(c.TelegramId); + } + } + } + + foreach (var id in ParseChatIds(chatIdsText)) + selectedIds.Add(id); + + if (selectedIds.Count > 0) + { + total = chats.Count(c => selectedIds.Contains(c.TelegramId)); + var knownIds = chats.Select(c => c.TelegramId).ToHashSet(); + targetChatIds = selectedIds + .Where(knownIds.Contains) + .OrderBy(x => x) + .ToList(); + } + } + } + if (total <= 0) { Snackbar.Add("未匹配到任何可操作的频道/群组(请先同步 Bot 频道数据,或检查 chat_id 列表)", Severity.Warning); @@ -121,7 +255,7 @@ ApiName = "模块页面", BotId = botId, UseAllChats = runUseAllChats, - ChatIds = chatIds.Where(x => x != 0).Distinct().OrderBy(x => x).ToList(), + ChatIds = runUseAllChats ? new List() : targetChatIds, UserId = userId, PermanentBan = permanentBan, RequestedAtUtc = DateTime.UtcNow @@ -150,31 +284,6 @@ } } - private async Task CountTargetsAsync(int botId, bool runUseAllChats, IReadOnlyCollection chatIds) - { - if (botId > 0) - { - var chats = (await BotManagement.GetChatsAsync(botId)).ToList(); - if (!runUseAllChats && chatIds.Count > 0) - chats = chats.Where(c => chatIds.Contains(c.TelegramId)).ToList(); - return chats.Count; - } - - // botId==0: all bots (force useAllChats) - var botsList = (await BotManagement.GetAllBotsAsync()) - .Where(b => b.IsActive) - .OrderBy(b => b.Id) - .ToList(); - - var total = 0; - foreach (var b in botsList) - { - var chats = await BotManagement.GetChatsAsync(b.Id); - total += chats.Count(); - } - return total; - } - private static List ParseChatIds(string raw) { raw = (raw ?? "").Trim(); From 7cac80f5282cb378f5fd0d663e2373e0c5238ad5 Mon Sep 17 00:00:00 2001 From: meoacgx Date: Thu, 22 Jan 2026 05:15:59 +0800 Subject: [PATCH 7/8] feat(kick): multi-line user id input --- .../Components/ModulePages/KickApiPage.razor | 106 ++++++++++++++---- 1 file changed, 83 insertions(+), 23 deletions(-) diff --git a/src/TelegramPanel.Web/Components/ModulePages/KickApiPage.razor b/src/TelegramPanel.Web/Components/ModulePages/KickApiPage.razor index 5b19542..d230e15 100644 --- a/src/TelegramPanel.Web/Components/ModulePages/KickApiPage.razor +++ b/src/TelegramPanel.Web/Components/ModulePages/KickApiPage.razor @@ -26,8 +26,13 @@ } - + + 已解析 @userIds.Count 个用户 ID @@ -69,6 +74,7 @@ } @@ -94,7 +100,8 @@ private List bots = new(); private List categories = new(); private int selectedBotId = 0; - private long userId; + private string userIdsText = ""; + private List userIds = new(); private bool permanentBan; private bool useAllChats = true; private HashSet selectedCategories = new(); @@ -103,7 +110,7 @@ private bool CanSubmit => !loading - && userId > 0 + && userIds.Count > 0 && (selectedBotId == 0 || bots.Any(x => x.Id == selectedBotId)) && ( selectedBotId == 0 @@ -142,6 +149,12 @@ } } + private async Task OnUserIdsChanged() + { + userIds = ParseLongIds(userIdsText); + await Task.CompletedTask; + } + private async Task OnSelectedBotChanged(int id) { var previous = selectedBotId; @@ -250,28 +263,55 @@ return; } - var log = new KickTaskLog - { - ApiName = "模块页面", - BotId = botId, - UseAllChats = runUseAllChats, - ChatIds = runUseAllChats ? new List() : targetChatIds, - UserId = userId, - PermanentBan = permanentBan, - RequestedAtUtc = DateTime.UtcNow - }; + var createdIds = new List(); + var failedCount = 0; + string? firstError = null; - var task = new BatchTask + foreach (var uid in userIds) { - TaskType = BatchTaskTypes.ExternalApiKick, - Total = total, - Completed = 0, - Failed = 0, - Config = JsonSerializer.Serialize(log, new JsonSerializerOptions { WriteIndented = true }) - }; + try + { + var log = new KickTaskLog + { + ApiName = $"模块页面 (user={uid})", + BotId = botId, + UseAllChats = runUseAllChats, + ChatIds = runUseAllChats ? new List() : targetChatIds, + UserId = uid, + PermanentBan = permanentBan, + RequestedAtUtc = DateTime.UtcNow + }; + + var task = new BatchTask + { + TaskType = BatchTaskTypes.ExternalApiKick, + Total = total, + Completed = 0, + Failed = 0, + Config = JsonSerializer.Serialize(log, new JsonSerializerOptions { WriteIndented = true }) + }; + + var created = await TaskManagement.CreateTaskAsync(task); + createdIds.Add(created.Id); + } + catch (Exception ex) + { + failedCount++; + firstError ??= ex.Message; + } + } + + if (createdIds.Count == 0) + { + Snackbar.Add($"提交失败:{firstError ?? "未知错误"}", Severity.Error); + return; + } + + if (failedCount > 0) + Snackbar.Add($"已提交 {createdIds.Count} 个任务,{failedCount} 个失败(首个错误:{firstError})", Severity.Warning); + else + Snackbar.Add($"已提交 {createdIds.Count} 个任务。请到【任务中心】查看进度。", Severity.Success); - var created = await TaskManagement.CreateTaskAsync(task); - Snackbar.Add($"已提交后台任务 #{created.Id}。请到【任务中心】查看进度。", Severity.Success); Navigation.NavigateTo("/tasks"); } catch (Exception ex) @@ -302,4 +342,24 @@ } return ids; } + + private static List ParseLongIds(string raw) + { + raw = (raw ?? "").Trim(); + if (raw.Length == 0) + return new List(); + + var parts = raw + .Replace("\r", "\n", StringComparison.Ordinal) + .Split(new[] { ',', ';', ' ', '\n', '\t' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + var set = new HashSet(); + foreach (var p in parts) + { + if (long.TryParse(p, out var v) && v > 0) + set.Add(v); + } + + return set.OrderBy(x => x).ToList(); + } } From 7997b25fc661656ef4cf910cfe61cea6e40cb82b Mon Sep 17 00:00:00 2001 From: meoacgx Date: Mon, 2 Mar 2026 23:07:52 +0800 Subject: [PATCH 8/8] =?UTF-8?q?feat:=20=E5=AE=8C=E6=95=B4=E6=94=AF?= =?UTF-8?q?=E6=8C=81=20tdata=20=E5=8E=8B=E7=BC=A9=E5=8C=85=E5=AF=BC?= =?UTF-8?q?=E5=85=A5=E5=B9=B6=E4=BF=AE=E5=A4=8D=E4=BC=9A=E8=AF=9D=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 导入链路新增 tdata 识别:Zip 无 .json 时自动识别 tdata 目录并进入专用导入流程 - 新增 TdataSessionBridge:调用 Node + @mtcute/convert 将 tdata 转为 Telethon session_string,再写入 WTelegram session - 修复 tdata 二次读取失败:ImportFromSessionFileAsync 增加 sessionKey 参数,tdata 导入时传入 apiHash,避免出现 Session exists but user not logged in - 强化登录态判定:SessionImporter 在 client.User 为空时补充 Users_GetUsers(Self) 兜底查询 - Docker 运行时改造:Dockerfile/Dockerfile.local 改为多阶段拷贝 Node 运行时并预装 @mtcute 依赖,规避 apt 源不可达导致的构建失败 - 导入页面文案更新:明确支持 tdata 包结构及全局 ApiId/ApiHash 前置要求 --- Dockerfile | 11 + Dockerfile.local | 12 +- .../Interfaces/ISessionImporter.cs | 3 +- .../Services/Telegram/AccountImportService.cs | 234 ++++++++++++++ .../Services/Telegram/SessionImporter.cs | 53 ++- .../Services/Telegram/TdataSessionBridge.cs | 305 ++++++++++++++++++ .../Components/Pages/AccountImport.razor | 2 + 7 files changed, 607 insertions(+), 13 deletions(-) create mode 100644 src/TelegramPanel.Core/Services/Telegram/TdataSessionBridge.cs diff --git a/Dockerfile b/Dockerfile index 9a96d8b..645657c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,10 +6,17 @@ COPY . . RUN dotnet restore "TelegramPanel.sln" RUN dotnet publish "src/TelegramPanel.Web/TelegramPanel.Web.csproj" -c Release -o /app/publish --no-restore +# tdata 运行时依赖(避免在容器内通过 apt 安装 Node) +FROM node:20-bookworm-slim AS tdata-runtime +WORKDIR /opt/telegram-panel-tdata-runtime +RUN printf '{\n "name": "telegram-panel-tdata-runtime",\n "private": true,\n "type": "module"\n}\n' > package.json \ + && npm install --silent @mtcute/convert @mtcute/node + FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final WORKDIR /app ENV ASPNETCORE_URLS=http://+:5000 +ENV TELEGRAM_PANEL_TDATA_RUNTIME_DIR=/app/tdata-runtime EXPOSE 5000 # 持久化目录:/data(通过 docker-compose 挂载) @@ -23,6 +30,10 @@ RUN mkdir -p /data /data/sessions /data/logs \ && ln -s /data/appsettings.local.json /app/appsettings.local.json || true COPY --from=build /app/publish . +COPY --from=tdata-runtime /usr/local /usr/local +COPY --from=tdata-runtime /opt/telegram-panel-tdata-runtime /app/tdata-runtime + +RUN node -v && npm -v ENTRYPOINT ["dotnet", "TelegramPanel.Web.dll"] diff --git a/Dockerfile.local b/Dockerfile.local index afd43ba..f9e6f7a 100644 --- a/Dockerfile.local +++ b/Dockerfile.local @@ -6,6 +6,12 @@ COPY . . RUN dotnet restore "TelegramPanel.sln" RUN dotnet publish "src/TelegramPanel.Web/TelegramPanel.Web.csproj" -c Release -o /app/publish --no-restore +# tdata 运行时依赖(避免在容器内通过 apt 安装 Node) +FROM node:20-bookworm-slim AS tdata-runtime +WORKDIR /opt/telegram-panel-tdata-runtime +RUN printf '{\n "name": "telegram-panel-tdata-runtime",\n "private": true,\n "type": "module"\n}\n' > package.json \ + && npm install --silent @mtcute/convert @mtcute/node + # 说明: # - 正常生产部署建议使用 mcr.microsoft.com/dotnet/aspnet:8.0 作为运行时镜像(更小) # - 若当前网络环境导致 Docker/WSL 无法拉取 aspnet 镜像,可临时使用本文件(运行时使用 sdk 镜像) @@ -13,6 +19,7 @@ FROM mcr.microsoft.com/dotnet/sdk:8.0 AS final WORKDIR /app ENV ASPNETCORE_URLS=http://+:5000 +ENV TELEGRAM_PANEL_TDATA_RUNTIME_DIR=/app/tdata-runtime EXPOSE 5000 # 持久化目录:/data(通过 docker-compose 挂载) @@ -26,6 +33,9 @@ RUN mkdir -p /data /data/sessions /data/logs \ && ln -s /data/appsettings.local.json /app/appsettings.local.json || true COPY --from=build /app/publish . +COPY --from=tdata-runtime /usr/local /usr/local +COPY --from=tdata-runtime /opt/telegram-panel-tdata-runtime /app/tdata-runtime + +RUN node -v && npm -v ENTRYPOINT ["dotnet", "TelegramPanel.Web.dll"] - diff --git a/src/TelegramPanel.Core/Interfaces/ISessionImporter.cs b/src/TelegramPanel.Core/Interfaces/ISessionImporter.cs index ce4241b..7d12bca 100644 --- a/src/TelegramPanel.Core/Interfaces/ISessionImporter.cs +++ b/src/TelegramPanel.Core/Interfaces/ISessionImporter.cs @@ -15,7 +15,8 @@ public interface ISessionImporter int apiId, string apiHash, long? userId = null, - string? phoneHint = null); + string? phoneHint = null, + string? sessionKey = null); /// /// 批量导入Session文件 diff --git a/src/TelegramPanel.Core/Services/Telegram/AccountImportService.cs b/src/TelegramPanel.Core/Services/Telegram/AccountImportService.cs index bd8fd78..aa4d8ce 100644 --- a/src/TelegramPanel.Core/Services/Telegram/AccountImportService.cs +++ b/src/TelegramPanel.Core/Services/Telegram/AccountImportService.cs @@ -245,6 +245,15 @@ public class AccountImportService var jsonFiles = Directory.EnumerateFiles(extractDir, "*.json", SearchOption.AllDirectories).ToList(); if (jsonFiles.Count == 0) { + var tdataDirs = FindTdataDirectories(extractDir); + if (tdataDirs.Count > 0) + { + results.AddRange(await ImportFromTdataDirectoriesAsync(tdataDirs, categoryId, twoFactorPassword)); + var tdataSuccess = results.Count(r => r.Success); + _logger.LogInformation("Tdata import completed: {Success}/{Total} successful", tdataSuccess, results.Count); + return results; + } + results.Add(new ImportResult(false, null, null, null, null, "压缩包内未找到任何 .json 文件")); return results; } @@ -477,6 +486,231 @@ public class AccountImportService string extractDirFallback() => Path.GetTempPath(); } + private async Task> ImportFromTdataDirectoriesAsync( + IReadOnlyCollection tdataDirectories, + int? categoryId, + string? twoFactorPassword) + { + var results = new List(); + + if (!TryGetGlobalTelegramApi(out var apiId, out var apiHash)) + { + results.Add(new ImportResult( + false, + null, + null, + null, + null, + "检测到 tdata 数据包,但系统未配置全局 Telegram API(ApiId/ApiHash);请先到【系统设置】配置后再导入")); + return results; + } + + var importedPhones = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var tdataDir in tdataDirectories.Distinct(StringComparer.OrdinalIgnoreCase)) + { + var result = await ImportFromTdataDirectoryAsync(tdataDir, apiId, apiHash, categoryId, twoFactorPassword); + var normalizedPhone = PhoneNumberFormatter.NormalizeToDigits(result.Phone); + if (!string.IsNullOrWhiteSpace(normalizedPhone)) + { + result = result with { Phone = normalizedPhone }; + if (!importedPhones.Add(normalizedPhone)) + { + results.Add(new ImportResult(false, normalizedPhone, result.UserId, result.Username, result.SessionPath, "重复账号已跳过")); + continue; + } + } + + results.Add(result); + } + + return results; + } + + private async Task ImportFromTdataDirectoryAsync( + string tdataDir, + int apiId, + string apiHash, + int? categoryId, + string? twoFactorPassword) + { + string? tempSessionPath = null; + try + { + var converted = await TdataSessionBridge.TryConvertToTelethonStringSessionAsync(tdataDir, _logger); + if (!converted.Ok || string.IsNullOrWhiteSpace(converted.SessionString)) + { + return new ImportResult(false, null, converted.UserId, null, null, + $"tdata 解析失败({tdataDir}):{converted.Error ?? "未知原因"}"); + } + + var phoneSeed = converted.UserId?.ToString() ?? Path.GetFileName(tdataDir); + if (string.IsNullOrWhiteSpace(phoneSeed)) + phoneSeed = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString(); + + tempSessionPath = Path.Combine(Path.GetTempPath(), $"telegram-panel-tdata-{Guid.NewGuid():N}.session"); + var writeResult = await SessionDataConverter.TryCreateWTelegramSessionFromSessionStringAsync( + sessionString: converted.SessionString, + apiId: apiId, + apiHash: apiHash, + targetSessionPath: tempSessionPath, + phone: phoneSeed, + userId: converted.UserId, + logger: _logger); + if (!writeResult.Ok) + { + return new ImportResult(false, null, converted.UserId, null, null, + $"tdata 会话转换失败:{writeResult.Reason ?? "未知原因"}"); + } + + var imported = await _sessionImporter.ImportFromSessionFileAsync( + tempSessionPath, + apiId, + apiHash, + userId: converted.UserId, + phoneHint: phoneSeed, + sessionKey: apiHash); + if (!imported.Success) + { + return new ImportResult( + false, + imported.Phone, + imported.UserId ?? converted.UserId, + imported.Username, + imported.SessionPath, + $"tdata 导入失败:{imported.Error ?? "未知原因"}"); + } + + return await PersistImportedSessionAsync(imported, apiId, apiHash, categoryId, twoFactorPassword); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to import tdata directory: {TdataDir}", tdataDir); + return new ImportResult(false, null, null, null, null, $"tdata 导入异常:{FormatException(ex)}"); + } + finally + { + try + { + if (!string.IsNullOrWhiteSpace(tempSessionPath) && File.Exists(tempSessionPath)) + File.Delete(tempSessionPath); + } + catch + { + // 忽略临时文件删除失败 + } + } + } + + private async Task PersistImportedSessionAsync( + ImportResult result, + int apiId, + string apiHash, + int? categoryId, + string? twoFactorPassword) + { + if (!result.Success || !result.UserId.HasValue) + return result; + + try + { + var phone = PhoneNumberFormatter.NormalizeToDigits(result.Phone); + if (string.IsNullOrWhiteSpace(phone)) + throw new InvalidOperationException("导入结果缺少有效手机号"); + + var existing = await _accountManagement.GetAccountByPhoneAsync(phone); + if (existing != null) + { + existing.UserId = result.UserId.Value; + existing.Username = result.Username; + existing.SessionPath = result.SessionPath!; + existing.ApiId = apiId; + existing.ApiHash = apiHash.Trim(); + existing.IsActive = true; + existing.CategoryId = categoryId ?? existing.CategoryId; + existing.LastSyncAt = DateTime.UtcNow; + if (!string.IsNullOrWhiteSpace(twoFactorPassword)) + existing.TwoFactorPassword = twoFactorPassword.Trim(); + await _accountManagement.UpdateAccountAsync(existing); + } + else + { + var account = new Account + { + Phone = phone, + UserId = result.UserId.Value, + Username = result.Username, + SessionPath = result.SessionPath!, + ApiId = apiId, + ApiHash = apiHash.Trim(), + IsActive = true, + CategoryId = categoryId, + TwoFactorPassword = string.IsNullOrWhiteSpace(twoFactorPassword) ? null : twoFactorPassword.Trim(), + CreatedAt = DateTime.UtcNow, + LastSyncAt = DateTime.UtcNow + }; + + await _accountManagement.CreateAccountAsync(account); + } + + _logger.LogInformation("Account saved to database: {Phone}", phone); + return result with { Phone = phone }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to save account to database: {Phone}", result.Phone); + return new ImportResult( + false, + result.Phone, + result.UserId, + result.Username, + result.SessionPath, + $"Session 已导入,但数据库保存失败:{FormatException(ex)}"); + } + } + + private bool TryGetGlobalTelegramApi(out int apiId, out string apiHash) + { + apiHash = (_configuration["Telegram:ApiHash"] ?? string.Empty).Trim(); + return int.TryParse(_configuration["Telegram:ApiId"], out apiId) + && apiId > 0 + && !string.IsNullOrWhiteSpace(apiHash); + } + + private static List FindTdataDirectories(string rootDirectory) + { + var result = new List(); + if (LooksLikeTdataDirectory(rootDirectory)) + result.Add(rootDirectory); + + foreach (var dir in Directory.EnumerateDirectories(rootDirectory, "*", SearchOption.AllDirectories)) + { + if (LooksLikeTdataDirectory(dir)) + result.Add(dir); + } + + return result + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + private static bool LooksLikeTdataDirectory(string directory) + { + try + { + if (!Directory.Exists(directory)) + return false; + + var hasKeyFile = File.Exists(Path.Combine(directory, "key_datas")) + || File.Exists(Path.Combine(directory, "key_data")); + var hasTdataData = Directory.EnumerateFileSystemEntries(directory, "D877F783D5D3EF8C*", SearchOption.TopDirectoryOnly).Any(); + return hasKeyFile || hasTdataData; + } + catch + { + return false; + } + } + /// /// 尝试从目录中读取 2fa.txt 文件内容作为二级密码 /// diff --git a/src/TelegramPanel.Core/Services/Telegram/SessionImporter.cs b/src/TelegramPanel.Core/Services/Telegram/SessionImporter.cs index 99ae924..b0cfcb9 100644 --- a/src/TelegramPanel.Core/Services/Telegram/SessionImporter.cs +++ b/src/TelegramPanel.Core/Services/Telegram/SessionImporter.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Configuration; using TelegramPanel.Core.Interfaces; +using TL; using WTelegram; namespace TelegramPanel.Core.Services.Telegram; @@ -24,7 +25,8 @@ public class SessionImporter : ISessionImporter int apiId, string apiHash, long? userId = null, - string? phoneHint = null) + string? phoneHint = null, + string? sessionKey = null) { if (!File.Exists(filePath)) { @@ -62,21 +64,36 @@ public class SessionImporter : ISessionImporter "api_id" => apiId.ToString(), "api_hash" => apiHash, "session_pathname" => targetPath, + "session_key" => string.IsNullOrWhiteSpace(sessionKey) ? null! : sessionKey, _ => null! }; using var client = new Client(Config); await client.ConnectAsync(); - if (client.User != null) + var self = client.User; + if (self == null) { - _logger.LogInformation("Session imported successfully for user {UserId}", client.User.id); + try + { + var users = await client.Users_GetUsers(InputUser.Self); + self = users.OfType().FirstOrDefault(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to fetch self user after session connect: {SessionPath}", targetPath); + } + } + + if (self != null) + { + _logger.LogInformation("Session imported successfully for user {UserId}", self.id); return new ImportResult( Success: true, - Phone: client.User.phone, - UserId: client.User.id, - Username: client.User.MainUsername, + Phone: self.phone, + UserId: self.id, + Username: self.MainUsername, SessionPath: targetPath ); } @@ -135,17 +152,31 @@ public class SessionImporter : ISessionImporter using var client = new Client(Config); await client.ConnectAsync(); - if (client.User != null) + var self = client.User; + if (self == null) + { + try + { + var users = await client.Users_GetUsers(InputUser.Self); + self = users.OfType().FirstOrDefault(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to fetch self user after string session connect: {SessionPath}", sessionPath); + } + } + + if (self != null) { // 重命名为手机号 - var newPath = Path.Combine(sessionsPath, $"{client.User.phone}.session"); + var newPath = Path.Combine(sessionsPath, $"{self.phone}.session"); File.Move(sessionPath, newPath, overwrite: true); return new ImportResult( Success: true, - Phone: client.User.phone, - UserId: client.User.id, - Username: client.User.MainUsername, + Phone: self.phone, + UserId: self.id, + Username: self.MainUsername, SessionPath: newPath ); } diff --git a/src/TelegramPanel.Core/Services/Telegram/TdataSessionBridge.cs b/src/TelegramPanel.Core/Services/Telegram/TdataSessionBridge.cs new file mode 100644 index 0000000..fef40b5 --- /dev/null +++ b/src/TelegramPanel.Core/Services/Telegram/TdataSessionBridge.cs @@ -0,0 +1,305 @@ +using System.Diagnostics; +using System.ComponentModel; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Logging; + +namespace TelegramPanel.Core.Services.Telegram; + +internal static class TdataSessionBridge +{ + private static readonly SemaphoreSlim SetupLock = new(1, 1); + private static readonly string RuntimeDir = ResolveRuntimeDir(); + + private const int NodeCheckTimeoutMs = 10_000; + private const int SetupTimeoutMs = 180_000; + private const int ConvertTimeoutMs = 60_000; + + private static string ResolveRuntimeDir() + { + var fromEnv = Environment.GetEnvironmentVariable("TELEGRAM_PANEL_TDATA_RUNTIME_DIR"); + if (!string.IsNullOrWhiteSpace(fromEnv)) + return fromEnv.Trim(); + return Path.Combine(Path.GetTempPath(), "telegram-panel-tdata-runtime"); + } + + private const string NodeScript = """ +import { convertFromTdata, convertToTelethonSession } from '@mtcute/convert'; + +const inputPath = process.argv[process.argv.length - 1]; + +try { + const session = await convertFromTdata({ path: inputPath, ignoreVersion: true }); + const sessionString = convertToTelethonSession(session); + const userId = session?.self?.userId ?? null; + console.log(JSON.stringify({ ok: true, sessionString, userId })); +} catch (error) { + const message = error?.stack ? String(error.stack) : String(error); + console.log(JSON.stringify({ ok: false, error: message })); + process.exit(1); +} +"""; + + public readonly record struct ConvertResult(bool Ok, string? SessionString, long? UserId, string? Error) + { + public static ConvertResult Success(string sessionString, long? userId) => new(true, sessionString, userId, null); + public static ConvertResult Fail(string error) => new(false, null, null, string.IsNullOrWhiteSpace(error) ? "未知错误" : error.Trim()); + } + + public static async Task TryConvertToTelethonStringSessionAsync( + string tdataDirectory, + ILogger logger, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(tdataDirectory) || !Directory.Exists(tdataDirectory)) + return ConvertResult.Fail("tdata 目录不存在"); + + var runtimeReady = await EnsureRuntimeReadyAsync(logger, cancellationToken); + if (!runtimeReady.Ok) + return ConvertResult.Fail(runtimeReady.Error ?? "tdata 运行环境初始化失败"); + + try + { + var run = await RunProcessAsync( + fileName: "node", + arguments: new[] { "--input-type=module", "-e", NodeScript, "--", Path.GetFullPath(tdataDirectory) }, + workingDirectory: RuntimeDir, + timeoutMs: ConvertTimeoutMs, + cancellationToken: cancellationToken); + + var output = PickJsonLine(run.StdOut); + if (string.IsNullOrWhiteSpace(output)) + { + var msg = string.IsNullOrWhiteSpace(run.StdErr) ? "node 输出为空" : run.StdErr.Trim(); + return ConvertResult.Fail(msg); + } + + using var doc = JsonDocument.Parse(output); + var root = doc.RootElement; + var ok = root.TryGetProperty("ok", out var okProp) && okProp.ValueKind == JsonValueKind.True; + + if (!ok) + { + var err = root.TryGetProperty("error", out var errProp) && errProp.ValueKind == JsonValueKind.String + ? (errProp.GetString() ?? string.Empty) + : (string.IsNullOrWhiteSpace(run.StdErr) ? "未知错误" : run.StdErr.Trim()); + return ConvertResult.Fail(err); + } + + if (!root.TryGetProperty("sessionString", out var sessionProp) || sessionProp.ValueKind != JsonValueKind.String) + return ConvertResult.Fail("转换输出缺少 sessionString"); + + var sessionString = (sessionProp.GetString() ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(sessionString)) + return ConvertResult.Fail("转换得到的 sessionString 为空"); + + long? userId = null; + if (root.TryGetProperty("userId", out var uidProp)) + { + if (uidProp.ValueKind == JsonValueKind.Number && uidProp.TryGetInt64(out var uid)) + userId = uid; + else if (uidProp.ValueKind == JsonValueKind.String && long.TryParse(uidProp.GetString(), out var uidStr)) + userId = uidStr; + } + + return ConvertResult.Success(sessionString, userId); + } + catch (TimeoutException ex) + { + logger.LogWarning(ex, "Tdata conversion timed out: {TdataDir}", tdataDirectory); + return ConvertResult.Fail("tdata 转换超时"); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Tdata conversion failed: {TdataDir}", tdataDirectory); + return ConvertResult.Fail(ex.Message); + } + } + + private static async Task<(bool Ok, string? Error)> EnsureRuntimeReadyAsync(ILogger logger, CancellationToken cancellationToken) + { + await SetupLock.WaitAsync(cancellationToken); + try + { + Directory.CreateDirectory(RuntimeDir); + + if (HasRequiredPackages()) + return (true, null); + + var nodeCheck = await RunProcessAsync( + fileName: "node", + arguments: new[] { "--version" }, + workingDirectory: RuntimeDir, + timeoutMs: NodeCheckTimeoutMs, + cancellationToken: cancellationToken); + if (nodeCheck.ExitCode != 0) + { + var err = string.IsNullOrWhiteSpace(nodeCheck.StdErr) ? "无法执行 node 命令" : nodeCheck.StdErr.Trim(); + return (false, $"Node.js 不可用:{err}"); + } + + var packageJson = Path.Combine(RuntimeDir, "package.json"); + if (!File.Exists(packageJson)) + { + const string packageJsonContent = """ +{ + "name": "telegram-panel-tdata-runtime", + "private": true, + "type": "module" +} +"""; + await File.WriteAllTextAsync(packageJson, packageJsonContent, Encoding.UTF8, cancellationToken); + } + + if (!HasRequiredPackages()) + { + logger.LogInformation("Installing tdata converter runtime dependencies in {RuntimeDir}", RuntimeDir); + var install = await RunProcessAsync( + fileName: "npm", + arguments: new[] { "install", "--silent", "@mtcute/convert", "@mtcute/node" }, + workingDirectory: RuntimeDir, + timeoutMs: SetupTimeoutMs, + cancellationToken: cancellationToken); + if (install.ExitCode != 0) + { + var err = string.IsNullOrWhiteSpace(install.StdErr) ? install.StdOut : install.StdErr; + return (false, $"npm 安装 tdata 依赖失败:{(err ?? string.Empty).Trim()}"); + } + } + + if (!HasRequiredPackages()) + return (false, "tdata 依赖安装后仍不可用"); + + return (true, null); + } + catch (TimeoutException ex) + { + logger.LogWarning(ex, "Preparing tdata runtime timed out"); + return (false, "准备 tdata 运行环境超时"); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Preparing tdata runtime failed"); + return (false, ex.Message); + } + finally + { + SetupLock.Release(); + } + } + + private static bool HasRequiredPackages() + { + if (!Directory.Exists(RuntimeDir)) + return false; + + var convertPkg = Path.Combine(RuntimeDir, "node_modules", "@mtcute", "convert", "package.json"); + var nodePkg = Path.Combine(RuntimeDir, "node_modules", "@mtcute", "node", "package.json"); + return File.Exists(convertPkg) && File.Exists(nodePkg); + } + + private static string? PickJsonLine(string? stdout) + { + if (string.IsNullOrWhiteSpace(stdout)) + return null; + + var lines = stdout + .Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries) + .Select(line => line.Trim()) + .Where(line => !string.IsNullOrWhiteSpace(line)) + .ToList(); + + for (var i = lines.Count - 1; i >= 0; i--) + { + var line = lines[i]; + if (line.StartsWith("{", StringComparison.Ordinal) && line.EndsWith("}", StringComparison.Ordinal)) + return line; + } + + return lines.Count > 0 ? lines[^1] : null; + } + + private readonly record struct ProcessResult(int ExitCode, string StdOut, string StdErr); + + private static async Task RunProcessAsync( + string fileName, + IReadOnlyCollection arguments, + string workingDirectory, + int timeoutMs, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(workingDirectory)) + throw new InvalidOperationException("进程工作目录不能为空"); + + Directory.CreateDirectory(workingDirectory); + + var stdOut = new StringBuilder(); + var stdErr = new StringBuilder(); + + using var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = fileName, + WorkingDirectory = workingDirectory, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + } + }; + + foreach (var argument in arguments) + process.StartInfo.ArgumentList.Add(argument); + + process.OutputDataReceived += (_, args) => + { + if (!string.IsNullOrWhiteSpace(args.Data)) + stdOut.AppendLine(args.Data); + }; + process.ErrorDataReceived += (_, args) => + { + if (!string.IsNullOrWhiteSpace(args.Data)) + stdErr.AppendLine(args.Data); + }; + + try + { + if (!process.Start()) + throw new InvalidOperationException($"无法启动进程:{fileName}"); + } + catch (Win32Exception ex) + { + throw new InvalidOperationException( + $"无法启动进程 `{fileName}`,请确认容器/系统已安装并加入 PATH。原始错误:{ex.Message}", + ex); + } + + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(timeoutMs); + + try + { + await process.WaitForExitAsync(timeoutCts.Token); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + try + { + if (!process.HasExited) + process.Kill(entireProcessTree: true); + } + catch + { + // ignore kill failure + } + + throw new TimeoutException($"{fileName} 执行超时(>{timeoutMs}ms)"); + } + + return new ProcessResult(process.ExitCode, stdOut.ToString(), stdErr.ToString()); + } +} diff --git a/src/TelegramPanel.Web/Components/Pages/AccountImport.razor b/src/TelegramPanel.Web/Components/Pages/AccountImport.razor index 5af1bcf..570d592 100644 --- a/src/TelegramPanel.Web/Components/Pages/AccountImport.razor +++ b/src/TelegramPanel.Web/Components/Pages/AccountImport.razor @@ -30,8 +30,10 @@
  • 单账号:Zip 根目录直接包含一个 .json + 一个 .session
  • 批量导入:每个账号一个独立子文件夹,文件夹内包含一个 .json + 一个 .session
  • +
  • tdata 协议包:支持 Zip 内包含 tdata 目录(含 key_datas / D877F783D5D3EF8C*);首次导入会自动安装解析依赖
  • 二级密码:自动解析账号目录下的 2fa.txt 文件作为二级密码保存到数据库
+
提示:导入 tdata 需要先在【系统设置】配置全局 Telegram API(ApiId/ApiHash)。
批量结构示例:
xx.zip
   ├─ 8613111111111