From 7699422c9acf3ac38ec0f74cc38c755ade5369d1 Mon Sep 17 00:00:00 2001 From: meoacgx Date: Mon, 2 Mar 2026 23:55:25 +0800 Subject: [PATCH 1/7] =?UTF-8?q?feat(export):=20=E6=94=AF=E6=8C=81=E5=AF=BC?= =?UTF-8?q?=E5=87=BA=E6=97=B6=E9=80=89=E6=8B=A9=20Telethon=20=E6=88=96=20T?= =?UTF-8?q?data=EF=BC=8C=E5=B9=B6=E5=9C=A8=20Tdata=20=E5=8C=85=E4=BF=9D?= =?UTF-8?q?=E7=95=99=20.session/.json?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增账号导出格式选择弹窗,前端导出接口追加 format 参数\n- 下载接口支持 format=tdata|telethon,并按格式生成文件名\n- 导出服务新增 Tdata 分支:保留原 .json/.session/2fa,同时附加 tdata 目录\n- 新增 WTelegram session -> Telethon string session 提取能力\n- 新增 Telethon string session -> tdata 转换方法,复用 Node 运行时桥接 --- .../Services/Telegram/SessionDataConverter.cs | 110 +++++++ .../Services/Telegram/TdataSessionBridge.cs | 91 +++++- .../Dialogs/ExportAccountFormatDialog.razor | 49 ++++ .../Components/Pages/Accounts.razor | 40 ++- src/TelegramPanel.Web/Program.cs | 10 +- .../Services/AccountExportService.cs | 273 ++++++++++++++---- 6 files changed, 512 insertions(+), 61 deletions(-) create mode 100644 src/TelegramPanel.Web/Components/Dialogs/ExportAccountFormatDialog.razor diff --git a/src/TelegramPanel.Core/Services/Telegram/SessionDataConverter.cs b/src/TelegramPanel.Core/Services/Telegram/SessionDataConverter.cs index 9eb5e37..9420070 100644 --- a/src/TelegramPanel.Core/Services/Telegram/SessionDataConverter.cs +++ b/src/TelegramPanel.Core/Services/Telegram/SessionDataConverter.cs @@ -19,6 +19,12 @@ public static class SessionDataConverter public static SessionConvertResult Fail(string? reason) => new(false, string.IsNullOrWhiteSpace(reason) ? "未知原因" : reason.Trim()); } + public readonly record struct TelethonStringSessionResult(bool Ok, string? SessionString, string? Reason) + { + public static TelethonStringSessionResult Success(string sessionString) => new(true, sessionString, null); + public static TelethonStringSessionResult Fail(string? reason) => new(false, null, string.IsNullOrWhiteSpace(reason) ? "未知原因" : reason.Trim()); + } + public static async Task TryConvertSqliteSessionFromJsonAsync( string phone, int apiId, @@ -395,6 +401,12 @@ public static class SessionDataConverter } } + private static string Base64UrlEncode(byte[] bytes) + { + var base64 = Convert.ToBase64String(bytes); + return base64.TrimEnd('=').Replace('+', '-').Replace('/', '_'); + } + private readonly record struct TelethonSessionData(int DcId, string IpAddress, ushort Port, byte[] AuthKey); private static bool TryParseTelethonStringSession(string sessionString, out TelethonSessionData data) @@ -438,6 +450,104 @@ public static class SessionDataConverter } } + public static TelethonStringSessionResult TryCreateTelethonStringSessionFromWTelegramSessionFile( + string sessionPath, + int apiId, + string apiHash, + string? phone, + long? userId, + ILogger logger) + { + try + { + var absoluteSessionPath = Path.GetFullPath(sessionPath); + if (!File.Exists(absoluteSessionPath)) + return TelethonStringSessionResult.Fail($"session 文件不存在:{absoluteSessionPath}"); + + string Config(string what) => what switch + { + "api_id" => apiId.ToString(), + "api_hash" => apiHash, + "session_key" => apiHash, + "session_pathname" => absoluteSessionPath, + "phone_number" => NormalizePhone(phone), + "user_id" => userId.HasValue && userId.Value > 0 ? userId.Value.ToString() : null!, + _ => null! + }; + + using var client = new Client(Config); + var clientType = typeof(Client); + var sessionField = clientType.GetField("_session", BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException("无法访问 WTelegram.Client._session"); + var sessionObj = sessionField.GetValue(client) ?? throw new InvalidOperationException("WTelegram session 未初始化"); + var sessionType = sessionObj.GetType(); + + var mainDc = (int?)sessionType.GetField("MainDC")?.GetValue(sessionObj) ?? 0; + var dcSessionsField = sessionType.GetField("DCSessions"); + var dcSessions = dcSessionsField?.GetValue(sessionObj) as System.Collections.IDictionary; + if (dcSessions == null || dcSessions.Count == 0) + return TelethonStringSessionResult.Fail("session 中未找到任何 DC 会话数据"); + + object? dcSessionObj = null; + if (mainDc > 0 && dcSessions.Contains(mainDc)) + dcSessionObj = dcSessions[mainDc]; + + if (dcSessionObj == null) + { + foreach (System.Collections.DictionaryEntry entry in dcSessions) + { + if (entry.Value != null) + { + dcSessionObj = entry.Value; + break; + } + } + } + + if (dcSessionObj == null) + return TelethonStringSessionResult.Fail("无法从 session 中解析出 DCSession"); + + var dcSessionType = dcSessionObj.GetType(); + var authKey = dcSessionType.GetField("AuthKey")?.GetValue(dcSessionObj) as byte[]; + if (authKey == null || authKey.Length != 256) + return TelethonStringSessionResult.Fail($"AuthKey 无效(长度:{authKey?.Length ?? 0},期望 256)"); + + var dataCenterObj = dcSessionType.GetField("DataCenter")?.GetValue(dcSessionObj) as DcOption; + if (dataCenterObj == null) + return TelethonStringSessionResult.Fail("无法从 session 中解析 DataCenter"); + + if (dataCenterObj.id <= 0 || dataCenterObj.id > byte.MaxValue) + return TelethonStringSessionResult.Fail($"DataCenter ID 无效:{dataCenterObj.id}"); + + if (string.IsNullOrWhiteSpace(dataCenterObj.ip_address)) + return TelethonStringSessionResult.Fail("DataCenter IP 为空"); + + if (dataCenterObj.port <= 0 || dataCenterObj.port > ushort.MaxValue) + return TelethonStringSessionResult.Fail($"DataCenter 端口无效:{dataCenterObj.port}"); + + if (!IPAddress.TryParse(dataCenterObj.ip_address.Trim(), out var ip)) + return TelethonStringSessionResult.Fail($"DataCenter IP 无法解析:{dataCenterObj.ip_address}"); + + var ipBytes = ip.GetAddressBytes(); + if (ipBytes.Length is not (4 or 16)) + return TelethonStringSessionResult.Fail($"DataCenter IP 字节长度无效:{ipBytes.Length}"); + + var packed = new byte[1 + ipBytes.Length + 2 + 256]; + packed[0] = (byte)dataCenterObj.id; + Buffer.BlockCopy(ipBytes, 0, packed, 1, ipBytes.Length); + BinaryPrimitives.WriteUInt16BigEndian(packed.AsSpan(1 + ipBytes.Length, 2), (ushort)dataCenterObj.port); + Buffer.BlockCopy(authKey, 0, packed, 1 + ipBytes.Length + 2, 256); + + var sessionString = "1" + Base64UrlEncode(packed); + return TelethonStringSessionResult.Success(sessionString); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to create telethon string session from WTelegram session file: {SessionPath}", sessionPath); + return TelethonStringSessionResult.Fail($"{ex.GetType().Name}: {ex.Message}"); + } + } + private static async Task WriteWTelegramSessionFileAsync( int apiId, string apiHash, diff --git a/src/TelegramPanel.Core/Services/Telegram/TdataSessionBridge.cs b/src/TelegramPanel.Core/Services/Telegram/TdataSessionBridge.cs index fef40b5..a51f96a 100644 --- a/src/TelegramPanel.Core/Services/Telegram/TdataSessionBridge.cs +++ b/src/TelegramPanel.Core/Services/Telegram/TdataSessionBridge.cs @@ -6,7 +6,7 @@ using Microsoft.Extensions.Logging; namespace TelegramPanel.Core.Services.Telegram; -internal static class TdataSessionBridge +public static class TdataSessionBridge { private static readonly SemaphoreSlim SetupLock = new(1, 1); private static readonly string RuntimeDir = ResolveRuntimeDir(); @@ -23,7 +23,7 @@ internal static class TdataSessionBridge return Path.Combine(Path.GetTempPath(), "telegram-panel-tdata-runtime"); } - private const string NodeScript = """ +private const string NodeScript = """ import { convertFromTdata, convertToTelethonSession } from '@mtcute/convert'; const inputPath = process.argv[process.argv.length - 1]; @@ -38,6 +38,23 @@ try { console.log(JSON.stringify({ ok: false, error: message })); process.exit(1); } +"""; + + private const string NodeScriptTelethonToTdata = """ +import { convertFromTelethonSession, convertToTdata } from '@mtcute/convert'; + +const telethonSession = process.argv[process.argv.length - 2]; +const outputDir = process.argv[process.argv.length - 1]; + +try { + const session = convertFromTelethonSession(telethonSession); + await convertToTdata(session, { path: outputDir }); + console.log(JSON.stringify({ ok: true })); +} 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) @@ -46,6 +63,12 @@ try { public static ConvertResult Fail(string error) => new(false, null, null, string.IsNullOrWhiteSpace(error) ? "未知错误" : error.Trim()); } + public readonly record struct ConvertToTdataResult(bool Ok, string? Error) + { + public static ConvertToTdataResult Success() => new(true, null); + public static ConvertToTdataResult Fail(string error) => new(false, string.IsNullOrWhiteSpace(error) ? "未知错误" : error.Trim()); + } + public static async Task TryConvertToTelethonStringSessionAsync( string tdataDirectory, ILogger logger, @@ -116,6 +139,70 @@ try { } } + public static async Task TryConvertTelethonStringSessionToTdataAsync( + string telethonSessionString, + string outputTdataDirectory, + ILogger logger, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(telethonSessionString)) + return ConvertToTdataResult.Fail("telethon session_string 为空"); + if (string.IsNullOrWhiteSpace(outputTdataDirectory)) + return ConvertToTdataResult.Fail("tdata 输出目录为空"); + + var runtimeReady = await EnsureRuntimeReadyAsync(logger, cancellationToken); + if (!runtimeReady.Ok) + return ConvertToTdataResult.Fail(runtimeReady.Error ?? "tdata 运行环境初始化失败"); + + try + { + var absoluteOutputDir = Path.GetFullPath(outputTdataDirectory); + if (Directory.Exists(absoluteOutputDir)) + Directory.Delete(absoluteOutputDir, recursive: true); + Directory.CreateDirectory(absoluteOutputDir); + + var run = await RunProcessAsync( + fileName: "node", + arguments: new[] { "--input-type=module", "-e", NodeScriptTelethonToTdata, "--", telethonSessionString.Trim(), absoluteOutputDir }, + 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 ConvertToTdataResult.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 ConvertToTdataResult.Fail(err); + } + + if (!Directory.EnumerateFileSystemEntries(absoluteOutputDir).Any()) + return ConvertToTdataResult.Fail("tdata 输出目录为空"); + + return ConvertToTdataResult.Success(); + } + catch (TimeoutException ex) + { + logger.LogWarning(ex, "Telethon->tdata conversion timed out"); + return ConvertToTdataResult.Fail("Telethon->tdata 转换超时"); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Telethon->tdata conversion failed"); + return ConvertToTdataResult.Fail(ex.Message); + } + } + private static async Task<(bool Ok, string? Error)> EnsureRuntimeReadyAsync(ILogger logger, CancellationToken cancellationToken) { await SetupLock.WaitAsync(cancellationToken); diff --git a/src/TelegramPanel.Web/Components/Dialogs/ExportAccountFormatDialog.razor b/src/TelegramPanel.Web/Components/Dialogs/ExportAccountFormatDialog.razor new file mode 100644 index 0000000..469d7a7 --- /dev/null +++ b/src/TelegramPanel.Web/Components/Dialogs/ExportAccountFormatDialog.razor @@ -0,0 +1,49 @@ + + + + 将导出 @AccountCount 个账号(@ScopeLabel)。 + + + 请选择导出格式: + + + + Telethon(默认) + 每个账号导出 `.json + .session (+2fa.txt)`,适合现有批量导入流程。 + + 导出 Telethon + + + + + Tdata + 每个账号额外导出 `tdata/`,并同时保留 `.json + .session (+2fa.txt)`。 + + 导出 Tdata + + + + + 取消 + + + +@code { + [CascadingParameter] private MudDialogInstance MudDialog { get; set; } = default!; + + [Parameter] public int AccountCount { get; set; } + [Parameter] public string ScopeLabel { get; set; } = "账号"; + + private void ExportTelethon() => Submit("telethon"); + private void ExportTdata() => Submit("tdata"); + + private void Submit(string format) + { + MudDialog.Close(DialogResult.Ok(format)); + } + + private void Cancel() + { + MudDialog.Cancel(); + } +} diff --git a/src/TelegramPanel.Web/Components/Pages/Accounts.razor b/src/TelegramPanel.Web/Components/Pages/Accounts.razor index e6d80c6..9835174 100644 --- a/src/TelegramPanel.Web/Components/Pages/Accounts.razor +++ b/src/TelegramPanel.Web/Components/Pages/Accounts.razor @@ -87,8 +87,8 @@ 批量改用户名(已选) 批量改Bio(已选) 删除已选 - 导出已选 - 导出当前页 + 导出已选 + 导出当前页 @if (selectedAccounts.Count > 0) @@ -927,7 +927,7 @@ } } - private void ExportSelectedAccounts() + private async Task ExportSelectedAccountsAsync() { var ids = selectedAccounts.Select(a => a.Id).Distinct().ToList(); if (ids.Count == 0) @@ -936,11 +936,10 @@ return; } - var qs = string.Join(",", ids); - Navigation.NavigateTo($"/downloads/accounts.zip?ids={qs}", forceLoad: true); + await ExportAccountsByIdsAsync(ids, "已选账号"); } - private void ExportCurrentPageAccounts() + private async Task ExportCurrentPageAccountsAsync() { var ids = accounts.Select(a => a.Id).Distinct().ToList(); if (ids.Count == 0) @@ -949,8 +948,35 @@ return; } + await ExportAccountsByIdsAsync(ids, "当前页账号"); + } + + private async Task ExportAccountsByIdsAsync(IReadOnlyList ids, string scopeLabel) + { + var format = await SelectExportFormatAsync(ids.Count, scopeLabel); + if (string.IsNullOrWhiteSpace(format)) + return; + var qs = string.Join(",", ids); - Navigation.NavigateTo($"/downloads/accounts.zip?ids={qs}", forceLoad: true); + Navigation.NavigateTo($"/downloads/accounts.zip?ids={qs}&format={format}", forceLoad: true); + } + + private async Task SelectExportFormatAsync(int accountCount, string scopeLabel) + { + var parameters = new DialogParameters + { + ["AccountCount"] = accountCount, + ["ScopeLabel"] = scopeLabel + }; + 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 } || result.Data is not string formatRaw) + return null; + + return string.Equals(formatRaw, "tdata", StringComparison.OrdinalIgnoreCase) + ? "tdata" + : "telethon"; } private async Task OpenSystemInbox(Account account) diff --git a/src/TelegramPanel.Web/Program.cs b/src/TelegramPanel.Web/Program.cs index 81187ba..463c034 100644 --- a/src/TelegramPanel.Web/Program.cs +++ b/src/TelegramPanel.Web/Program.cs @@ -856,8 +856,14 @@ var accountsZipDownload = app.MapGet("/downloads/accounts.zip", async ( var all = (await accountManagement.GetAllAccountsAsync()).ToList(); var accounts = ids == null ? all : all.Where(a => ids.Contains(a.Id)).ToList(); - var zipBytes = await exporter.BuildAccountsZipAsync(accounts, cancellationToken); - var fileName = $"telegram-panel-accounts-{DateTime.UtcNow:yyyyMMdd-HHmmss}.zip"; + var formatRaw = (http.Request.Query["format"].ToString() ?? string.Empty).Trim(); + var format = string.Equals(formatRaw, "tdata", StringComparison.OrdinalIgnoreCase) + ? AccountExportFormat.Tdata + : AccountExportFormat.Telethon; + + var zipBytes = await exporter.BuildAccountsZipAsync(accounts, cancellationToken, format); + var formatName = format == AccountExportFormat.Tdata ? "tdata" : "telethon"; + var fileName = $"telegram-panel-accounts-{formatName}-{DateTime.UtcNow:yyyyMMdd-HHmmss}.zip"; return Results.File(zipBytes, "application/zip", fileName); }).DisableAntiforgery(); if (adminAuthEnabled) diff --git a/src/TelegramPanel.Web/Services/AccountExportService.cs b/src/TelegramPanel.Web/Services/AccountExportService.cs index 33d6712..867c9d6 100644 --- a/src/TelegramPanel.Web/Services/AccountExportService.cs +++ b/src/TelegramPanel.Web/Services/AccountExportService.cs @@ -3,10 +3,17 @@ using System.Text; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using TelegramPanel.Core.Interfaces; +using TelegramPanel.Core.Services.Telegram; using TelegramPanel.Data.Entities; namespace TelegramPanel.Web.Services; +public enum AccountExportFormat +{ + Telethon = 0, + Tdata = 1 +} + public class AccountExportService { private readonly IWebHostEnvironment _environment; @@ -26,20 +33,15 @@ public class AccountExportService _clientPool = clientPool; } - public async Task BuildAccountsZipAsync(IReadOnlyList accounts, CancellationToken cancellationToken) + public async Task BuildAccountsZipAsync( + IReadOnlyList accounts, + CancellationToken cancellationToken, + AccountExportFormat format = AccountExportFormat.Telethon) { using var ms = new MemoryStream(); using (var zip = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true)) { - var readme = zip.CreateEntry("README.txt", CompressionLevel.Fastest); - await using (var readmeStream = readme.Open()) - await using (var writer = new StreamWriter(readmeStream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false))) - { - await writer.WriteLineAsync("Telegram Panel 账号导出包"); - await writer.WriteLineAsync("结构:每个账号一个子文件夹,包含 .json + .session;如保存了二级密码,则额外包含 2fa.txt。"); - await writer.WriteLineAsync("导入:面板 -> 账号 -> 导入账号 -> 压缩包导入(Zip)。"); - await writer.WriteLineAsync($"导出时间(UTC):{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss}"); - } + await WriteReadmeAsync(zip, format); foreach (var account in accounts) { @@ -48,61 +50,47 @@ public class AccountExportService var phone = (account.Phone ?? string.Empty).Trim(); var safeFolder = BuildSafeFolderName(phone, account.Id); - // 文件夹占位(部分 zip 工具更友好) _ = zip.CreateEntry($"{safeFolder}/"); try { var sessionPath = ResolveSessionPath(account); + var hasSession = false; if (!File.Exists(sessionPath)) { _logger.LogWarning("Session file missing for account {AccountId}: {Path}", account.Id, sessionPath); - var warn = zip.CreateEntry($"{safeFolder}/WARN.txt", CompressionLevel.Fastest); - await using var warnStream = warn.Open(); - await using var warnWriter = new StreamWriter(warnStream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); - await warnWriter.WriteLineAsync($"未找到 session 文件:{sessionPath}"); + await WriteTextEntryAsync(zip, $"{safeFolder}/WARN.txt", $"未找到 session 文件:{sessionPath}"); } else { - // 若 session 被本进程的 Telegram 客户端锁定,先移除客户端释放文件锁,然后重试读取 await CopySessionWithRetryAsync(zip, safeFolder, sessionPath, account.Id, cancellationToken); + hasSession = true; } - var jsonEntry = zip.CreateEntry($"{safeFolder}/{safeFolder}.json", CompressionLevel.Fastest); - await using (var jsonStream = jsonEntry.Open()) - await using (var writer = new StreamWriter(jsonStream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false))) - { - // 兼容当前 Zip 导入逻辑:json 里需要 api_id/api_hash/phone - var json = System.Text.Json.JsonSerializer.Serialize(new - { - phone = phone, - user_id = account.UserId > 0 ? (long?)account.UserId : null, - username = account.Username, - first_name = account.Nickname, - api_id = account.ApiId, - api_hash = account.ApiHash, - exported_at_utc = DateTime.UtcNow - }, new System.Text.Json.JsonSerializerOptions { WriteIndented = true }); - - await writer.WriteAsync(json); - } + await WriteAccountJsonAsync(zip, safeFolder, account, phone); var twoFactorPassword = (account.TwoFactorPassword ?? string.Empty).Trim(); if (!string.IsNullOrWhiteSpace(twoFactorPassword)) + await WriteTextEntryAsync(zip, $"{safeFolder}/2fa.txt", twoFactorPassword); + + if (format == AccountExportFormat.Tdata) { - var twoFaEntry = zip.CreateEntry($"{safeFolder}/2fa.txt", CompressionLevel.Fastest); - await using var twoFaStream = twoFaEntry.Open(); - await using var twoFaWriter = new StreamWriter(twoFaStream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); - await twoFaWriter.WriteAsync(twoFactorPassword); + if (!hasSession) + { + await WriteTextEntryAsync(zip, $"{safeFolder}/WARN_TDATA.txt", "无法生成 tdata:session 文件不存在"); + } + else + { + var tdataResult = await TryAddTdataPackageAsync(zip, safeFolder, account, sessionPath, cancellationToken); + if (!tdataResult.Ok) + await WriteTextEntryAsync(zip, $"{safeFolder}/WARN_TDATA.txt", $"tdata 生成失败:{tdataResult.Error}"); + } } } catch (Exception ex) { _logger.LogError(ex, "Failed to export account {AccountId}", account.Id); - var err = zip.CreateEntry($"{safeFolder}/ERROR.txt", CompressionLevel.Fastest); - await using var errStream = err.Open(); - await using var errWriter = new StreamWriter(errStream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); - await errWriter.WriteLineAsync($"导出该账号时发生异常:{ex.Message}"); + await WriteTextEntryAsync(zip, $"{safeFolder}/ERROR.txt", $"导出该账号时发生异常:{ex.Message}"); } } } @@ -110,6 +98,198 @@ public class AccountExportService return ms.ToArray(); } + private async Task WriteReadmeAsync(ZipArchive zip, AccountExportFormat format) + { + var readme = zip.CreateEntry("README.txt", CompressionLevel.Fastest); + await using var readmeStream = readme.Open(); + await using var writer = new StreamWriter(readmeStream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + + await writer.WriteLineAsync("Telegram Panel 账号导出包"); + await writer.WriteLineAsync($"导出时间(UTC):{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss}"); + await writer.WriteLineAsync($"导出格式:{(format == AccountExportFormat.Tdata ? "tdata(包含 tdata + .json + .session)" : "telethon(.json + .session)")}"); + await writer.WriteLineAsync(); + await writer.WriteLineAsync("通用结构:每个账号一个子文件夹,至少包含 .json + .session;如保存了二级密码,则额外包含 2fa.txt。"); + if (format == AccountExportFormat.Tdata) + await writer.WriteLineAsync("tdata 结构:每个账号目录下额外包含 tdata/ 子目录(key_datas / D877F783D5D3EF8C*)。"); + await writer.WriteLineAsync("导入:面板 -> 账号 -> 导入账号 -> 压缩包导入(Zip)。"); + } + + private async Task WriteAccountJsonAsync(ZipArchive zip, string safeFolder, Account account, string phone) + { + var jsonEntry = zip.CreateEntry($"{safeFolder}/{safeFolder}.json", CompressionLevel.Fastest); + await using var jsonStream = jsonEntry.Open(); + await using var writer = new StreamWriter(jsonStream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + + var json = System.Text.Json.JsonSerializer.Serialize(new + { + phone = phone, + user_id = account.UserId > 0 ? (long?)account.UserId : null, + username = account.Username, + first_name = account.Nickname, + api_id = account.ApiId, + api_hash = account.ApiHash, + exported_at_utc = DateTime.UtcNow + }, new System.Text.Json.JsonSerializerOptions { WriteIndented = true }); + + await writer.WriteAsync(json); + } + + private readonly record struct TdataExportResult(bool Ok, string? Error) + { + public static TdataExportResult Success() => new(true, null); + public static TdataExportResult Fail(string? error) => new(false, string.IsNullOrWhiteSpace(error) ? "未知原因" : error.Trim()); + } + + private async Task TryAddTdataPackageAsync( + ZipArchive zip, + string safeFolder, + Account account, + string sourceSessionPath, + CancellationToken cancellationToken) + { + string? tempSessionPath = null; + string? tempTdataDir = null; + + try + { + if (account.ApiId <= 0 || string.IsNullOrWhiteSpace(account.ApiHash)) + return TdataExportResult.Fail("账号缺少 ApiId 或 ApiHash"); + + tempSessionPath = Path.Combine(Path.GetTempPath(), $"telegram-panel-export-{Guid.NewGuid():N}.session"); + tempTdataDir = Path.Combine(Path.GetTempPath(), $"telegram-panel-export-tdata-{Guid.NewGuid():N}"); + + // 若 session 被当前进程锁定,先释放客户端后重试复制 + await CopySessionFileToPathWithRetryAsync( + sourceSessionPath: sourceSessionPath, + targetSessionPath: tempSessionPath, + accountId: account.Id, + cancellationToken: cancellationToken); + + var telethonResult = SessionDataConverter.TryCreateTelethonStringSessionFromWTelegramSessionFile( + sessionPath: tempSessionPath, + apiId: account.ApiId, + apiHash: account.ApiHash.Trim(), + phone: account.Phone, + userId: account.UserId > 0 ? account.UserId : null, + logger: _logger); + if (!telethonResult.Ok || string.IsNullOrWhiteSpace(telethonResult.SessionString)) + return TdataExportResult.Fail($"生成 Telethon session_string 失败:{telethonResult.Reason}"); + + var tdataResult = await TdataSessionBridge.TryConvertTelethonStringSessionToTdataAsync( + telethonSessionString: telethonResult.SessionString, + outputTdataDirectory: tempTdataDir, + logger: _logger, + cancellationToken: cancellationToken); + if (!tdataResult.Ok) + return TdataExportResult.Fail(tdataResult.Error); + + await AddDirectoryToZipAsync( + zip: zip, + sourceDirectory: tempTdataDir, + zipDirectoryPrefix: $"{safeFolder}/tdata", + cancellationToken: cancellationToken); + + return TdataExportResult.Success(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to build tdata package for account {AccountId}", account.Id); + return TdataExportResult.Fail(ex.Message); + } + finally + { + try + { + if (!string.IsNullOrWhiteSpace(tempSessionPath) && File.Exists(tempSessionPath)) + File.Delete(tempSessionPath); + } + catch + { + // ignore + } + + try + { + if (!string.IsNullOrWhiteSpace(tempTdataDir) && Directory.Exists(tempTdataDir)) + Directory.Delete(tempTdataDir, recursive: true); + } + catch + { + // ignore + } + } + } + + private async Task CopySessionFileToPathWithRetryAsync( + string sourceSessionPath, + string targetSessionPath, + int accountId, + CancellationToken cancellationToken) + { + const int maxAttempts = 3; + Directory.CreateDirectory(Path.GetDirectoryName(targetSessionPath) ?? Path.GetTempPath()); + + for (var attempt = 1; attempt <= maxAttempts; attempt++) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + await using var source = new FileStream(sourceSessionPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); + await using var target = new FileStream(targetSessionPath, FileMode.Create, FileAccess.Write, FileShare.None); + await source.CopyToAsync(target, cancellationToken); + return; + } + catch (IOException ex) when (attempt < maxAttempts) + { + _logger.LogWarning(ex, "Session file is locked while preparing tdata export (attempt {Attempt}/{Max}) for account {AccountId}: {Path}", + attempt, maxAttempts, accountId, sourceSessionPath); + try + { + await _clientPool.RemoveClientAsync(accountId); + } + catch (Exception removeEx) + { + _logger.LogDebug(removeEx, "Failed to remove client for account {AccountId} while preparing tdata export", accountId); + } + + await Task.Delay(200, cancellationToken); + } + } + + throw new IOException($"session 文件被占用无法读取:{sourceSessionPath}"); + } + + private static async Task AddDirectoryToZipAsync( + ZipArchive zip, + string sourceDirectory, + string zipDirectoryPrefix, + CancellationToken cancellationToken) + { + var normalizedPrefix = zipDirectoryPrefix.TrimEnd('/').Replace('\\', '/'); + _ = zip.CreateEntry($"{normalizedPrefix}/"); + + foreach (var filePath in Directory.EnumerateFiles(sourceDirectory, "*", SearchOption.AllDirectories)) + { + cancellationToken.ThrowIfCancellationRequested(); + + var relativePath = Path.GetRelativePath(sourceDirectory, filePath).Replace('\\', '/'); + var entryPath = $"{normalizedPrefix}/{relativePath}"; + var entry = zip.CreateEntry(entryPath, CompressionLevel.Fastest); + + await using var entryStream = entry.Open(); + await using var fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); + await fileStream.CopyToAsync(entryStream, cancellationToken); + } + } + + private static async Task WriteTextEntryAsync(ZipArchive zip, string path, string content) + { + var entry = zip.CreateEntry(path, CompressionLevel.Fastest); + await using var entryStream = entry.Open(); + await using var writer = new StreamWriter(entryStream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + await writer.WriteAsync(content ?? string.Empty); + } + private async Task CopySessionWithRetryAsync( ZipArchive zip, string safeFolder, @@ -133,7 +313,6 @@ public class AccountExportService { _logger.LogWarning(ex, "Session file is locked (attempt {Attempt}/{Max}) for account {AccountId}: {Path}", attempt, maxAttempts, accountId, sessionPath); - // 尝试释放本进程内的文件锁(WTelegramClient 会锁 session) try { await _clientPool.RemoveClientAsync(accountId); @@ -147,12 +326,7 @@ public class AccountExportService } } - // 最终失败:写出说明文件,不抛出导致整个导出失败 - var warn = zip.CreateEntry($"{safeFolder}/WARN.txt", CompressionLevel.Fastest); - await using var warnStream = warn.Open(); - await using var warnWriter = new StreamWriter(warnStream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); - await warnWriter.WriteLineAsync($"session 文件被占用无法读取:{sessionPath}"); - await warnWriter.WriteLineAsync("建议:先在面板停止该账号的 Telegram 客户端(或重启应用)后再导出。"); + await WriteTextEntryAsync(zip, $"{safeFolder}/WARN.txt", $"session 文件被占用无法读取:{sessionPath}\n建议:先在面板停止该账号的 Telegram 客户端(或重启应用)后再导出。"); } private string ResolveSessionPath(Account account) @@ -168,7 +342,6 @@ public class AccountExportService if (Path.IsPathRooted(sessionPath)) return Path.GetFullPath(sessionPath); - // SessionPath 通常存的是相对路径(例如 sessions/.session),以 ContentRoot 作为基准更稳定 var combined = Path.GetFullPath(Path.Combine(_environment.ContentRootPath, sessionPath)); if (File.Exists(combined)) return combined; From 8c9a3d5a60b5870526b264aff02e1198cb260fc6 Mon Sep 17 00:00:00 2001 From: meoacgx Date: Tue, 3 Mar 2026 00:15:05 +0800 Subject: [PATCH 2/7] =?UTF-8?q?fix(export):=20=E4=BF=AE=E5=A4=8D=E5=AF=BC?= =?UTF-8?q?=E5=87=BA=20tdata=20=E6=97=A0=E6=B3=95=E8=A2=AB=E5=AE=98?= =?UTF-8?q?=E6=96=B9=E5=AE=A2=E6=88=B7=E7=AB=AF=E7=9B=B4=E6=8E=A5=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 保留 Telethon session_string 的 base64 padding(尾部 '='),避免 auth key 被截断为 248 字节\n- 在 Telethon->tdata 转换前增加 session_string 规范化,自动补齐缺失 padding\n- 解决导出 tdata 在官方客户端同级目录启动后仍要求重新登录的问题 --- .../Services/Telegram/SessionDataConverter.cs | 4 +++- .../Services/Telegram/TdataSessionBridge.cs | 20 ++++++++++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/TelegramPanel.Core/Services/Telegram/SessionDataConverter.cs b/src/TelegramPanel.Core/Services/Telegram/SessionDataConverter.cs index 9420070..7a7145f 100644 --- a/src/TelegramPanel.Core/Services/Telegram/SessionDataConverter.cs +++ b/src/TelegramPanel.Core/Services/Telegram/SessionDataConverter.cs @@ -404,7 +404,9 @@ public static class SessionDataConverter private static string Base64UrlEncode(byte[] bytes) { var base64 = Convert.ToBase64String(bytes); - return base64.TrimEnd('=').Replace('+', '-').Replace('/', '_'); + // 注意:@mtcute/convert 在 Telethon->tdata 转换时,要求保留 base64 padding(尾部 '=')。 + // 若去掉 padding,可能会把 auth key 解析成 248 字节,导致导出的 tdata 无法被官方客户端使用。 + return base64.Replace('+', '-').Replace('/', '_'); } private readonly record struct TelethonSessionData(int DcId, string IpAddress, ushort Port, byte[] AuthKey); diff --git a/src/TelegramPanel.Core/Services/Telegram/TdataSessionBridge.cs b/src/TelegramPanel.Core/Services/Telegram/TdataSessionBridge.cs index a51f96a..4311a74 100644 --- a/src/TelegramPanel.Core/Services/Telegram/TdataSessionBridge.cs +++ b/src/TelegramPanel.Core/Services/Telegram/TdataSessionBridge.cs @@ -156,6 +156,7 @@ try { try { + var normalizedSession = NormalizeTelethonSessionString(telethonSessionString); var absoluteOutputDir = Path.GetFullPath(outputTdataDirectory); if (Directory.Exists(absoluteOutputDir)) Directory.Delete(absoluteOutputDir, recursive: true); @@ -163,7 +164,7 @@ try { var run = await RunProcessAsync( fileName: "node", - arguments: new[] { "--input-type=module", "-e", NodeScriptTelethonToTdata, "--", telethonSessionString.Trim(), absoluteOutputDir }, + arguments: new[] { "--input-type=module", "-e", NodeScriptTelethonToTdata, "--", normalizedSession, absoluteOutputDir }, workingDirectory: RuntimeDir, timeoutMs: ConvertTimeoutMs, cancellationToken: cancellationToken); @@ -203,6 +204,23 @@ try { } } + private static string NormalizeTelethonSessionString(string sessionString) + { + var trimmed = sessionString.Trim(); + if (trimmed.Length < 2 || !char.IsDigit(trimmed[0])) + return trimmed; + + var body = trimmed[1..]; + var mod = body.Length % 4; + if (mod == 0) + return trimmed; + if (mod == 1) + return trimmed; // 非法长度,保持原样让下游报错更明确 + + var padding = mod == 2 ? "==" : "="; + return $"{trimmed}{padding}"; + } + private static async Task<(bool Ok, string? Error)> EnsureRuntimeReadyAsync(ILogger logger, CancellationToken cancellationToken) { await SetupLock.WaitAsync(cancellationToken); From 4947f7a9ad9bf3901d3f58f5a8c800718e498d24 Mon Sep 17 00:00:00 2001 From: meoacgx Date: Tue, 3 Mar 2026 00:25:44 +0800 Subject: [PATCH 3/7] =?UTF-8?q?fix(export):=20=E5=AF=BC=E5=87=BA=20tdata?= =?UTF-8?q?=20=E6=97=B6=E4=BC=98=E5=85=88=E9=80=89=E6=8B=A9=E5=B7=B2?= =?UTF-8?q?=E6=8E=88=E6=9D=83=20DCSession=EF=BC=8C=E9=81=BF=E5=85=8D?= =?UTF-8?q?=E7=94=9F=E6=88=90=E6=9C=AA=E7=99=BB=E5=BD=95=E4=BC=9A=E8=AF=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 导出 Telethon session_string 时改为多候选打分:优先 _dcSession、UserId>0、匹配目标 userId、匹配 MainDC\n- 新增 DCSession 解析辅助方法,避免误取未授权 key\n- 保留 base64 padding 输出,确保 auth key 长度稳定为 256\n- 增加导出日志,便于定位 DC 选择结果 --- .../Services/Telegram/SessionDataConverter.cs | 119 ++++++++++++++---- 1 file changed, 98 insertions(+), 21 deletions(-) diff --git a/src/TelegramPanel.Core/Services/Telegram/SessionDataConverter.cs b/src/TelegramPanel.Core/Services/Telegram/SessionDataConverter.cs index 7a7145f..9ba3ecb 100644 --- a/src/TelegramPanel.Core/Services/Telegram/SessionDataConverter.cs +++ b/src/TelegramPanel.Core/Services/Telegram/SessionDataConverter.cs @@ -479,6 +479,7 @@ public static class SessionDataConverter using var client = new Client(Config); var clientType = typeof(Client); + var currentDcSessionField = clientType.GetField("_dcSession", BindingFlags.Instance | BindingFlags.NonPublic); var sessionField = clientType.GetField("_session", BindingFlags.Instance | BindingFlags.NonPublic) ?? throw new InvalidOperationException("无法访问 WTelegram.Client._session"); var sessionObj = sessionField.GetValue(client) ?? throw new InvalidOperationException("WTelegram session 未初始化"); @@ -490,33 +491,55 @@ public static class SessionDataConverter if (dcSessions == null || dcSessions.Count == 0) return TelethonStringSessionResult.Fail("session 中未找到任何 DC 会话数据"); - object? dcSessionObj = null; - if (mainDc > 0 && dcSessions.Contains(mainDc)) - dcSessionObj = dcSessions[mainDc]; + var candidateSessions = new List<(object DcSessionObj, bool IsCurrentDc)>(); + var currentDcSessionObj = currentDcSessionField?.GetValue(client); + if (currentDcSessionObj != null) + candidateSessions.Add((currentDcSessionObj, true)); - if (dcSessionObj == null) + if (mainDc > 0 && dcSessions.Contains(mainDc) && dcSessions[mainDc] != null) + candidateSessions.Add((dcSessions[mainDc]!, false)); + + foreach (System.Collections.DictionaryEntry entry in dcSessions) { - foreach (System.Collections.DictionaryEntry entry in dcSessions) - { - if (entry.Value != null) - { - dcSessionObj = entry.Value; - break; - } - } + if (entry.Value != null) + candidateSessions.Add((entry.Value, false)); } - if (dcSessionObj == null) - return TelethonStringSessionResult.Fail("无法从 session 中解析出 DCSession"); + byte[]? authKey = null; + DcOption? dataCenterObj = null; + long pickedDcUserId = 0; + var bestScore = int.MinValue; - var dcSessionType = dcSessionObj.GetType(); - var authKey = dcSessionType.GetField("AuthKey")?.GetValue(dcSessionObj) as byte[]; - if (authKey == null || authKey.Length != 256) - return TelethonStringSessionResult.Fail($"AuthKey 无效(长度:{authKey?.Length ?? 0},期望 256)"); + foreach (var (dcSessionObj, isCurrentDc) in candidateSessions) + { + if (!TryExtractDcSessionInfo(dcSessionObj, out var candidateAuthKey, out var candidateDataCenter, out var dcUserId)) + continue; + if (candidateAuthKey == null || candidateAuthKey.Length != 256) + continue; + if (candidateDataCenter == null) + continue; - var dataCenterObj = dcSessionType.GetField("DataCenter")?.GetValue(dcSessionObj) as DcOption; - if (dataCenterObj == null) - return TelethonStringSessionResult.Fail("无法从 session 中解析 DataCenter"); + var score = 0; + if (isCurrentDc) + score += 100; + if (dcUserId > 0) + score += 50; + if (userId.HasValue && userId.Value > 0 && dcUserId == userId.Value) + score += 40; + if (mainDc > 0 && candidateDataCenter.id == mainDc) + score += 20; + + if (score <= bestScore) + continue; + + bestScore = score; + authKey = candidateAuthKey; + dataCenterObj = candidateDataCenter; + pickedDcUserId = dcUserId; + } + + if (authKey == null || authKey.Length != 256 || dataCenterObj == null) + return TelethonStringSessionResult.Fail("无法从 session 中解析有效的已授权 DCSession(AuthKey/DataCenter)"); if (dataCenterObj.id <= 0 || dataCenterObj.id > byte.MaxValue) return TelethonStringSessionResult.Fail($"DataCenter ID 无效:{dataCenterObj.id}"); @@ -541,6 +564,13 @@ public static class SessionDataConverter Buffer.BlockCopy(authKey, 0, packed, 1 + ipBytes.Length + 2, 256); var sessionString = "1" + Base64UrlEncode(packed); + logger.LogInformation( + "Picked DCSession for Telethon export: mainDc={MainDc}, dcId={DcId}, dcUserId={DcUserId}, targetUserId={TargetUserId}, isIpV6={IsIpV6}", + mainDc, + dataCenterObj.id, + pickedDcUserId, + userId, + ipBytes.Length == 16); return TelethonStringSessionResult.Success(sessionString); } catch (Exception ex) @@ -550,6 +580,53 @@ public static class SessionDataConverter } } + private static bool TryExtractDcSessionInfo( + object dcSessionObj, + out byte[]? authKey, + out DcOption? dataCenter, + out long dcUserId) + { + try + { + var dcSessionType = dcSessionObj.GetType(); + authKey = dcSessionType.GetField("AuthKey")?.GetValue(dcSessionObj) as byte[]; + dataCenter = dcSessionType.GetField("DataCenter")?.GetValue(dcSessionObj) as DcOption; + + dcUserId = 0; + var userIdObj = dcSessionType.GetField("UserId")?.GetValue(dcSessionObj); + switch (userIdObj) + { + case int i: + dcUserId = i; + break; + case long l: + dcUserId = l; + break; + case uint ui: + dcUserId = ui; + break; + case ulong ul: + dcUserId = (long)Math.Min(ul, long.MaxValue); + break; + case short s: + dcUserId = s; + break; + case ushort us: + dcUserId = us; + break; + } + + return authKey != null && dataCenter != null; + } + catch + { + authKey = null; + dataCenter = null; + dcUserId = 0; + return false; + } + } + private static async Task WriteWTelegramSessionFileAsync( int apiId, string apiHash, From 57683f8de88205b0cb5b0d27c3ec43ae76df6fe4 Mon Sep 17 00:00:00 2001 From: meoacgx Date: Tue, 3 Mar 2026 00:38:11 +0800 Subject: [PATCH 4/7] =?UTF-8?q?fix(export):=20=E7=A6=81=E6=AD=A2=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7=E5=AF=BC=E5=87=BA=E4=B8=8B=E8=BD=BD=E7=BC=93=E5=AD=98?= =?UTF-8?q?=EF=BC=8C=E9=81=BF=E5=85=8D=E9=87=8D=E5=A4=8D=E6=8B=BF=E5=88=B0?= =?UTF-8?q?=E6=97=A7=20tdata=20=E5=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 前端下载地址追加 ts 时间戳参数,强制每次请求唯一 URL\n- 后端 /downloads/accounts.zip 返回 no-store/no-cache 头,禁止浏览器复用旧响应\n- 修复导出后仍下载到历史坏包(旧 tdata 无法登录)的现象 --- src/TelegramPanel.Web/Components/Pages/Accounts.razor | 3 ++- src/TelegramPanel.Web/Program.cs | 5 +++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/TelegramPanel.Web/Components/Pages/Accounts.razor b/src/TelegramPanel.Web/Components/Pages/Accounts.razor index 9835174..fbbb826 100644 --- a/src/TelegramPanel.Web/Components/Pages/Accounts.razor +++ b/src/TelegramPanel.Web/Components/Pages/Accounts.razor @@ -958,7 +958,8 @@ return; var qs = string.Join(",", ids); - Navigation.NavigateTo($"/downloads/accounts.zip?ids={qs}&format={format}", forceLoad: true); + var ts = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + Navigation.NavigateTo($"/downloads/accounts.zip?ids={qs}&format={format}&ts={ts}", forceLoad: true); } private async Task SelectExportFormatAsync(int accountCount, string scopeLabel) diff --git a/src/TelegramPanel.Web/Program.cs b/src/TelegramPanel.Web/Program.cs index 463c034..b190113 100644 --- a/src/TelegramPanel.Web/Program.cs +++ b/src/TelegramPanel.Web/Program.cs @@ -864,6 +864,11 @@ var accountsZipDownload = app.MapGet("/downloads/accounts.zip", async ( var zipBytes = await exporter.BuildAccountsZipAsync(accounts, cancellationToken, format); var formatName = format == AccountExportFormat.Tdata ? "tdata" : "telethon"; var fileName = $"telegram-panel-accounts-{formatName}-{DateTime.UtcNow:yyyyMMdd-HHmmss}.zip"; + + // 下载文件禁止缓存,避免浏览器复用旧的 accounts.zip 导致“重复拿到旧导出包”。 + http.Response.Headers.CacheControl = "no-store, no-cache, must-revalidate, max-age=0"; + http.Response.Headers.Pragma = "no-cache"; + http.Response.Headers.Expires = "0"; return Results.File(zipBytes, "application/zip", fileName); }).DisableAntiforgery(); if (adminAuthEnabled) From eec251ab827c71c260902be451b915ab572fecb2 Mon Sep 17 00:00:00 2001 From: meoacgx Date: Tue, 3 Mar 2026 00:47:30 +0800 Subject: [PATCH 5/7] =?UTF-8?q?fix(export):=20=E7=94=9F=E6=88=90=20tdata?= =?UTF-8?q?=20=E6=97=B6=E5=86=99=E5=85=A5=E8=B4=A6=E5=8F=B7=20user=5Fid?= =?UTF-8?q?=EF=BC=8C=E4=BF=AE=E5=A4=8D=E6=A1=8C=E9=9D=A2=E7=AB=AF=E4=BB=8D?= =?UTF-8?q?=E8=A6=81=E6=B1=82=E7=99=BB=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Telethon->tdata 桥接脚本新增 userId 参数并注入 session.self.userId\n- AccountExportService 调用转换时传入当前账号 UserId\n- 避免 @mtcute/convert 默认写入 userId=0 导致 Telegram Desktop 识别为未登录 --- .../Services/Telegram/TdataSessionBridge.cs | 15 +++++++++++++-- .../Services/AccountExportService.cs | 1 + 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/TelegramPanel.Core/Services/Telegram/TdataSessionBridge.cs b/src/TelegramPanel.Core/Services/Telegram/TdataSessionBridge.cs index 4311a74..70683a1 100644 --- a/src/TelegramPanel.Core/Services/Telegram/TdataSessionBridge.cs +++ b/src/TelegramPanel.Core/Services/Telegram/TdataSessionBridge.cs @@ -43,11 +43,21 @@ try { private const string NodeScriptTelethonToTdata = """ import { convertFromTelethonSession, convertToTdata } from '@mtcute/convert'; -const telethonSession = process.argv[process.argv.length - 2]; +const telethonSession = process.argv[process.argv.length - 3]; +const userIdRaw = process.argv[process.argv.length - 2]; const outputDir = process.argv[process.argv.length - 1]; try { const session = convertFromTelethonSession(telethonSession); + const userId = Number.parseInt(userIdRaw, 10); + if (Number.isFinite(userId) && userId > 0) { + session.self = { + userId, + isBot: false, + isPremium: false, + usernames: [], + }; + } await convertToTdata(session, { path: outputDir }); console.log(JSON.stringify({ ok: true })); } catch (error) { @@ -141,6 +151,7 @@ try { public static async Task TryConvertTelethonStringSessionToTdataAsync( string telethonSessionString, + long? userId, string outputTdataDirectory, ILogger logger, CancellationToken cancellationToken = default) @@ -164,7 +175,7 @@ try { var run = await RunProcessAsync( fileName: "node", - arguments: new[] { "--input-type=module", "-e", NodeScriptTelethonToTdata, "--", normalizedSession, absoluteOutputDir }, + arguments: new[] { "--input-type=module", "-e", NodeScriptTelethonToTdata, "--", normalizedSession, (userId ?? 0).ToString(), absoluteOutputDir }, workingDirectory: RuntimeDir, timeoutMs: ConvertTimeoutMs, cancellationToken: cancellationToken); diff --git a/src/TelegramPanel.Web/Services/AccountExportService.cs b/src/TelegramPanel.Web/Services/AccountExportService.cs index 867c9d6..73e6fb6 100644 --- a/src/TelegramPanel.Web/Services/AccountExportService.cs +++ b/src/TelegramPanel.Web/Services/AccountExportService.cs @@ -177,6 +177,7 @@ public class AccountExportService var tdataResult = await TdataSessionBridge.TryConvertTelethonStringSessionToTdataAsync( telethonSessionString: telethonResult.SessionString, + userId: account.UserId > 0 ? account.UserId : null, outputTdataDirectory: tempTdataDir, logger: _logger, cancellationToken: cancellationToken); From a4669f8561fae42d8a1a29bdae6b63f4c991c605 Mon Sep 17 00:00:00 2001 From: meoacgx Date: Tue, 3 Mar 2026 01:01:41 +0800 Subject: [PATCH 6/7] =?UTF-8?q?docs(modules):=20=E8=A1=A5=E5=85=85?= =?UTF-8?q?=E8=B4=A6=E5=8F=B7=E6=95=B0=E6=8D=AE=E5=8C=85=E5=AF=BC=E5=87=BA?= =?UTF-8?q?=E8=B0=83=E7=94=A8=E8=AF=B4=E6=98=8E=E4=B8=8E=20tdata=20?= =?UTF-8?q?=E5=85=B3=E9=94=AE=E6=B3=A8=E6=84=8F=E4=BA=8B=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增模块复用 AccountExportService 的推荐方式\n- 补充 /downloads/accounts.zip 参数与鉴权/缓存注意\n- 记录 tdata 导出稳定条件:padding、user_id 注入、已授权 DCSession 选择\n- 便于后续模块实现按账号下载 telethon/tdata 数据包 --- docs/developer/modules.md | 45 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/docs/developer/modules.md b/docs/developer/modules.md index 02b866e..a75b9f0 100644 --- a/docs/developer/modules.md +++ b/docs/developer/modules.md @@ -257,6 +257,51 @@ public sealed class MyHandler : IModuleTaskHandler } ``` +## 账号导出下载(Telethon / Tdata) + +如果模块需要“下载某个账号的数据包”,建议优先使用宿主服务直接生成 Zip(同进程内调用),避免绕 HTTP 鉴权与 Cookie。 + +### 推荐方式:模块内直接调用导出服务 + +可注入: + +- `TelegramPanel.Web.Services.AccountExportService` +- `TelegramPanel.Core.Services.AccountManagementService` + +核心调用链: + +1. 先通过 `AccountManagementService` 获取目标账号(或账号列表) +2. 调用 `AccountExportService.BuildAccountsZipAsync(accounts, ct, format)` +3. 将 `byte[]` 按模块自己的场景返回/落盘/上传 + +其中 `format`: + +- `AccountExportFormat.Telethon`:导出 `.json + .session (+2fa.txt)` +- `AccountExportFormat.Tdata`:在以上基础上额外导出 `tdata/` + +### HTTP 方式(备选) + +宿主现有下载接口: + +- `GET /downloads/accounts.zip` +- Query: + - `ids=1,2,3`(可选,不传则导出全部) + - `format=telethon|tdata`(不传默认 `telethon`) + - `ts=`(可选,建议带上,避免浏览器缓存旧包) + +注意: + +- 若开启后台登录,接口受登录态保护(需带管理端 Cookie) +- 响应已设置 `no-store/no-cache`,但调用方仍建议加 `ts` + +### Tdata 导出的实现要点(后续扩展必须保持) + +1. `session -> telethon string` 时必须保留 Base64 padding(尾部 `=`) +2. `telethon string -> tdata` 时必须注入 `session.self.userId` +3. 生成 `telethon string` 时要优先选择“已授权 DCSession”(不是任意 DC) + +否则会出现“包结构看似正常,但 Telegram Desktop 仍要求重新登录”。 + ## UI 模块项目模板(Razor 组件) 如果你的模块需要提供页面(`IModuleUiProvider.GetPages`),推荐把模块做成 `Microsoft.NET.Sdk.Razor` 项目(类似 Razor Class Library),例如: From 8a6cdd48686fba1e70883743f667169c91559df7 Mon Sep 17 00:00:00 2001 From: meoacgx Date: Tue, 3 Mar 2026 01:26:50 +0800 Subject: [PATCH 7/7] =?UTF-8?q?feat(bot-channels):=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E5=88=A0=E9=99=A4=E9=A2=91=E9=81=93=E4=B8=8E=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E6=A3=80=E6=B5=8B=E7=AD=9B=E9=80=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Services/BotManagementService.cs | 19 + .../Services/Telegram/BotTelegramService.cs | 127 ++++ src/TelegramPanel.Data/AppDbContext.cs | 2 + src/TelegramPanel.Data/Entities/BotChannel.cs | 15 + ...1929_AddBotChannelStatusFields.Designer.cs | 632 ++++++++++++++++++ ...0260302171929_AddBotChannelStatusFields.cs | 59 ++ .../Migrations/AppDbContextModelSnapshot.cs | 12 + .../Repositories/BotChannelRepository.cs | 15 +- .../Repositories/IBotChannelRepository.cs | 1 + .../Components/Pages/BotChannelsHome.razor | 235 +++++++ 10 files changed, 1115 insertions(+), 2 deletions(-) create mode 100644 src/TelegramPanel.Data/Migrations/20260302171929_AddBotChannelStatusFields.Designer.cs create mode 100644 src/TelegramPanel.Data/Migrations/20260302171929_AddBotChannelStatusFields.cs diff --git a/src/TelegramPanel.Core/Services/BotManagementService.cs b/src/TelegramPanel.Core/Services/BotManagementService.cs index 316621f..b4d0d2f 100644 --- a/src/TelegramPanel.Core/Services/BotManagementService.cs +++ b/src/TelegramPanel.Core/Services/BotManagementService.cs @@ -273,6 +273,7 @@ public class BotManagementService int botId, int? categoryId, string? search, + int statusFilter, int pageIndex, int pageSize, CancellationToken cancellationToken = default) @@ -282,6 +283,7 @@ public class BotManagementService categoryId: categoryId, broadcastOnly: true, search: search, + statusFilter: statusFilter, pageIndex: pageIndex, pageSize: pageSize, cancellationToken: cancellationToken); @@ -348,6 +350,23 @@ public class BotManagementService await _botChannelRepository.DeleteAsync(ch); } + public async Task UpdateChannelStatusAsync(int botId, long telegramId, bool ok, string? error, DateTime checkedAtUtc) + { + var ch = await _botChannelRepository.GetByTelegramIdAsync(botId, telegramId); + if (ch == null) + return; + + ch.ChannelStatusOk = ok; + ch.ChannelStatusCheckedAtUtc = checkedAtUtc; + ch.ChannelStatusError = ok + ? null + : string.IsNullOrWhiteSpace(error) + ? "检测失败" + : error.Trim()[..Math.Min(error.Trim().Length, 500)]; + + await _botChannelRepository.UpdateAsync(ch); + } + public async Task SetChannelCategoryAsync(int botChannelId, int? categoryId) { var ch = await _botChannelRepository.GetByIdAsync(botChannelId); diff --git a/src/TelegramPanel.Core/Services/Telegram/BotTelegramService.cs b/src/TelegramPanel.Core/Services/Telegram/BotTelegramService.cs index 9dd86af..c2ad2cf 100644 --- a/src/TelegramPanel.Core/Services/Telegram/BotTelegramService.cs +++ b/src/TelegramPanel.Core/Services/Telegram/BotTelegramService.cs @@ -37,6 +37,7 @@ public class BotTelegramService } public sealed record BotChannelSyncResult(int AppliedUpdates, int RemovedStale); + public sealed record ChannelStatusCheckResult(int SuccessCount, int FailedCount, int TotalCount, IReadOnlyDictionary Failures); /// /// 手动同步(新增 + 清理): @@ -632,6 +633,132 @@ public class BotTelegramService return map; } + /// + /// 批量检测 Bot 在频道中的可操作状态: + /// 通过 sendMessage 发送探测消息并立即 deleteMessage;发送失败则记为异常。 + /// + public async Task CheckChannelsStatusAsync( + int botId, + IReadOnlyList channelTelegramIds, + CancellationToken cancellationToken) + { + var bot = await _botManagement.GetBotAsync(botId) + ?? throw new InvalidOperationException($"机器人不存在:{botId}"); + + if (!bot.IsActive) + throw new InvalidOperationException("该机器人已停用"); + + var ids = channelTelegramIds + .Where(x => x != 0) + .Distinct() + .ToList(); + + if (ids.Count == 0) + return new ChannelStatusCheckResult(0, 0, 0, new Dictionary()); + + var failures = new Dictionary(); + + var delayMs = ReadInt(_configuration, "Telegram:DefaultDelayMs", 1000); + if (delayMs < 0) delayMs = 0; + if (delayMs > 10000) delayMs = 10000; + + var maxRetries = ReadInt(_configuration, "Telegram:MaxRetries", 1); + if (maxRetries < 0) maxRetries = 0; + if (maxRetries > 5) maxRetries = 5; + + var ok = 0; + for (var index = 0; index < ids.Count; index++) + { + var chatId = ids[index]; + cancellationToken.ThrowIfCancellationRequested(); + + var attempt = 0; + while (true) + { + try + { + await ProbeChannelBySendDeleteAsync(bot.Token, chatId, cancellationToken); + await _botManagement.UpdateChannelStatusAsync(bot.Id, chatId, ok: true, error: null, checkedAtUtc: DateTime.UtcNow); + ok++; + break; + } + catch (InvalidOperationException ex) when (TryGetRetryAfterSeconds(ex.Message, out var seconds)) + { + attempt++; + if (attempt > maxRetries) + { + var msg = string.IsNullOrWhiteSpace(ex.Message) ? "检测失败(限流重试超限)" : ex.Message.Trim(); + failures[chatId] = msg; + await _botManagement.UpdateChannelStatusAsync(bot.Id, chatId, ok: false, error: msg, checkedAtUtc: DateTime.UtcNow); + _logger.LogWarning( + "CheckChannelsStatus retry exceeded: bot={BotId} chat={ChatId} retryAfter={RetryAfter}s", + botId, + chatId, + seconds); + break; + } + + _logger.LogWarning( + "CheckChannelsStatus hit rate limit, retry after {Seconds}s (attempt {Attempt}/{Max}): bot={BotId} chat={ChatId}", + seconds, + attempt, + maxRetries, + botId, + chatId); + await Task.Delay(TimeSpan.FromSeconds(seconds), cancellationToken); + } + catch (Exception ex) + { + var msg = string.IsNullOrWhiteSpace(ex.Message) ? "检测失败" : ex.Message.Trim(); + failures[chatId] = msg; + await _botManagement.UpdateChannelStatusAsync(bot.Id, chatId, ok: false, error: msg, checkedAtUtc: DateTime.UtcNow); + _logger.LogWarning(ex, "CheckChannelsStatus failed: bot={BotId} chat={ChatId}", botId, chatId); + break; + } + } + + if (delayMs > 0 && index < ids.Count - 1) + { + var jitter = Random.Shared.Next(0, Math.Min(300, delayMs + 1)); + await Task.Delay(delayMs + jitter, cancellationToken); + } + } + + return new ChannelStatusCheckResult(ok, failures.Count, ids.Count, failures); + } + + private async Task ProbeChannelBySendDeleteAsync(string token, long chatId, CancellationToken cancellationToken) + { + var probeText = $"频道状态检测 {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss}"; + var sendResult = await _api.CallAsync(token, "sendMessage", new Dictionary + { + ["chat_id"] = chatId.ToString(), + ["text"] = probeText, + ["disable_notification"] = "true" + }, cancellationToken); + + if (sendResult.ValueKind != JsonValueKind.Object + || !sendResult.TryGetProperty("message_id", out var messageIdEl) + || !messageIdEl.TryGetInt64(out var messageId)) + { + throw new InvalidOperationException("Bot API sendMessage 返回格式异常,无法完成频道检测"); + } + + try + { + await _api.CallAsync(token, "deleteMessage", new Dictionary + { + ["chat_id"] = chatId.ToString(), + ["message_id"] = messageId.ToString() + }, cancellationToken); + } + catch (Exception ex) + { + // 删除失败不判定为“频道异常”,但记录日志便于排查(例如 Bot 缺少删消息权限)。 + _logger.LogWarning(ex, "Probe deleteMessage failed: chat={ChatId}", chatId); + } + } + private static bool TryParseChatMemberUpdate(JsonElement myChatMember, out BotApiChat chat, out string status) { chat = default; diff --git a/src/TelegramPanel.Data/AppDbContext.cs b/src/TelegramPanel.Data/AppDbContext.cs index 17095d8..6a449cf 100644 --- a/src/TelegramPanel.Data/AppDbContext.cs +++ b/src/TelegramPanel.Data/AppDbContext.cs @@ -177,10 +177,12 @@ public class AppDbContext : DbContext entity.Property(e => e.Title).IsRequired().HasMaxLength(200); entity.Property(e => e.Username).HasMaxLength(100); entity.Property(e => e.About).HasMaxLength(1000); + entity.Property(e => e.ChannelStatusError).HasMaxLength(500); entity.HasIndex(e => e.TelegramId).IsUnique(); entity.HasIndex(e => e.Username); entity.HasIndex(e => e.CategoryId); + entity.HasIndex(e => e.ChannelStatusOk); entity.HasOne(e => e.Category) .WithMany(c => c.Channels) diff --git a/src/TelegramPanel.Data/Entities/BotChannel.cs b/src/TelegramPanel.Data/Entities/BotChannel.cs index 9b7b77a..edd9467 100644 --- a/src/TelegramPanel.Data/Entities/BotChannel.cs +++ b/src/TelegramPanel.Data/Entities/BotChannel.cs @@ -16,6 +16,21 @@ public class BotChannel public DateTime? CreatedAt { get; set; } public DateTime SyncedAt { get; set; } = DateTime.UtcNow; + /// + /// 频道状态检测结果:true=正常,false=异常,null=未检测 + /// + public bool? ChannelStatusOk { get; set; } + + /// + /// 最近一次频道状态检测时间(UTC) + /// + public DateTime? ChannelStatusCheckedAtUtc { get; set; } + + /// + /// 最近一次检测失败原因(仅异常时有值) + /// + public string? ChannelStatusError { get; set; } + public int? CategoryId { get; set; } public BotChannelCategory? Category { get; set; } diff --git a/src/TelegramPanel.Data/Migrations/20260302171929_AddBotChannelStatusFields.Designer.cs b/src/TelegramPanel.Data/Migrations/20260302171929_AddBotChannelStatusFields.Designer.cs new file mode 100644 index 0000000..31e34c1 --- /dev/null +++ b/src/TelegramPanel.Data/Migrations/20260302171929_AddBotChannelStatusFields.Designer.cs @@ -0,0 +1,632 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using TelegramPanel.Data; + +#nullable disable + +namespace TelegramPanel.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260302171929_AddBotChannelStatusFields")] + partial class AddBotChannelStatusFields + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.22"); + + modelBuilder.Entity("TelegramPanel.Data.Entities.Account", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApiHash") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("ApiId") + .HasColumnType("INTEGER"); + + b.Property("CategoryId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("LastLoginAt") + .HasColumnType("TEXT"); + + b.Property("LastSyncAt") + .HasColumnType("TEXT"); + + b.Property("Nickname") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Phone") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("SessionPath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("TelegramStatusCheckedAtUtc") + .HasColumnType("TEXT"); + + b.Property("TelegramStatusDetails") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("TelegramStatusOk") + .HasColumnType("INTEGER"); + + b.Property("TelegramStatusSummary") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("TwoFactorPassword") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("Username") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId"); + + b.HasIndex("Phone") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("Accounts"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.AccountCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Color") + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ExcludeFromOperations") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("AccountCategories"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.AccountChannel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccountId") + .HasColumnType("INTEGER"); + + b.Property("ChannelId") + .HasColumnType("INTEGER"); + + b.Property("IsAdmin") + .HasColumnType("INTEGER"); + + b.Property("IsCreator") + .HasColumnType("INTEGER"); + + b.Property("SyncedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId"); + + b.HasIndex("AccountId", "ChannelId") + .IsUnique(); + + b.ToTable("AccountChannels"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.BatchTask", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Completed") + .HasColumnType("INTEGER"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("Config") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Failed") + .HasColumnType("INTEGER"); + + b.Property("StartedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("TaskType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Total") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("Status"); + + b.ToTable("BatchTasks"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.Bot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("LastSyncAt") + .HasColumnType("TEXT"); + + b.Property("LastUpdateId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Username") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("Username"); + + b.ToTable("Bots"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("About") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("AccessHash") + .HasColumnType("INTEGER"); + + b.Property("CategoryId") + .HasColumnType("INTEGER"); + + b.Property("ChannelStatusCheckedAtUtc") + .HasColumnType("TEXT"); + + b.Property("ChannelStatusError") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ChannelStatusOk") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("IsBroadcast") + .HasColumnType("INTEGER"); + + b.Property("MemberCount") + .HasColumnType("INTEGER"); + + b.Property("SyncedAt") + .HasColumnType("TEXT"); + + b.Property("TelegramId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Username") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId"); + + b.HasIndex("ChannelStatusOk"); + + b.HasIndex("TelegramId") + .IsUnique(); + + b.HasIndex("Username"); + + b.ToTable("BotChannels"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannelCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("BotChannelCategories"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannelMember", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BotChannelId") + .HasColumnType("INTEGER"); + + b.Property("BotId") + .HasColumnType("INTEGER"); + + b.Property("SyncedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("BotChannelId"); + + b.HasIndex("BotId"); + + b.HasIndex("BotId", "BotChannelId") + .IsUnique(); + + b.ToTable("BotChannelMembers"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.Channel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("About") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("AccessHash") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorAccountId") + .HasColumnType("INTEGER"); + + b.Property("GroupId") + .HasColumnType("INTEGER"); + + b.Property("IsBroadcast") + .HasColumnType("INTEGER"); + + b.Property("MemberCount") + .HasColumnType("INTEGER"); + + b.Property("SyncedAt") + .HasColumnType("TEXT"); + + b.Property("TelegramId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Username") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatorAccountId"); + + b.HasIndex("GroupId"); + + b.HasIndex("TelegramId") + .IsUnique(); + + b.HasIndex("Username"); + + b.ToTable("Channels"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.ChannelGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ChannelGroups"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("About") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("AccessHash") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorAccountId") + .HasColumnType("INTEGER"); + + b.Property("MemberCount") + .HasColumnType("INTEGER"); + + b.Property("SyncedAt") + .HasColumnType("TEXT"); + + b.Property("TelegramId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Username") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatorAccountId"); + + b.HasIndex("TelegramId") + .IsUnique(); + + b.HasIndex("Username"); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.Account", b => + { + b.HasOne("TelegramPanel.Data.Entities.AccountCategory", "Category") + .WithMany("Accounts") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Category"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.AccountChannel", b => + { + b.HasOne("TelegramPanel.Data.Entities.Account", "Account") + .WithMany("AccountChannels") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TelegramPanel.Data.Entities.Channel", "Channel") + .WithMany("AccountChannels") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Account"); + + b.Navigation("Channel"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannel", b => + { + b.HasOne("TelegramPanel.Data.Entities.BotChannelCategory", "Category") + .WithMany("Channels") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Category"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannelMember", b => + { + b.HasOne("TelegramPanel.Data.Entities.BotChannel", "BotChannel") + .WithMany("Members") + .HasForeignKey("BotChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TelegramPanel.Data.Entities.Bot", "Bot") + .WithMany("ChannelMembers") + .HasForeignKey("BotId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Bot"); + + b.Navigation("BotChannel"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.Channel", b => + { + b.HasOne("TelegramPanel.Data.Entities.Account", "CreatorAccount") + .WithMany("Channels") + .HasForeignKey("CreatorAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("TelegramPanel.Data.Entities.ChannelGroup", "Group") + .WithMany("Channels") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("CreatorAccount"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.Group", b => + { + b.HasOne("TelegramPanel.Data.Entities.Account", "CreatorAccount") + .WithMany("Groups") + .HasForeignKey("CreatorAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CreatorAccount"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.Account", b => + { + b.Navigation("AccountChannels"); + + b.Navigation("Channels"); + + b.Navigation("Groups"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.AccountCategory", b => + { + b.Navigation("Accounts"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.Bot", b => + { + b.Navigation("ChannelMembers"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannel", b => + { + b.Navigation("Members"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannelCategory", b => + { + b.Navigation("Channels"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.Channel", b => + { + b.Navigation("AccountChannels"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.ChannelGroup", b => + { + b.Navigation("Channels"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/TelegramPanel.Data/Migrations/20260302171929_AddBotChannelStatusFields.cs b/src/TelegramPanel.Data/Migrations/20260302171929_AddBotChannelStatusFields.cs new file mode 100644 index 0000000..c92a086 --- /dev/null +++ b/src/TelegramPanel.Data/Migrations/20260302171929_AddBotChannelStatusFields.cs @@ -0,0 +1,59 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TelegramPanel.Data.Migrations +{ + /// + public partial class AddBotChannelStatusFields : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ChannelStatusCheckedAtUtc", + table: "BotChannels", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "ChannelStatusError", + table: "BotChannels", + type: "TEXT", + maxLength: 500, + nullable: true); + + migrationBuilder.AddColumn( + name: "ChannelStatusOk", + table: "BotChannels", + type: "INTEGER", + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_BotChannels_ChannelStatusOk", + table: "BotChannels", + column: "ChannelStatusOk"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_BotChannels_ChannelStatusOk", + table: "BotChannels"); + + migrationBuilder.DropColumn( + name: "ChannelStatusCheckedAtUtc", + table: "BotChannels"); + + migrationBuilder.DropColumn( + name: "ChannelStatusError", + table: "BotChannels"); + + migrationBuilder.DropColumn( + name: "ChannelStatusOk", + table: "BotChannels"); + } + } +} diff --git a/src/TelegramPanel.Data/Migrations/AppDbContextModelSnapshot.cs b/src/TelegramPanel.Data/Migrations/AppDbContextModelSnapshot.cs index 4c5a4f8..1eba4bc 100644 --- a/src/TelegramPanel.Data/Migrations/AppDbContextModelSnapshot.cs +++ b/src/TelegramPanel.Data/Migrations/AppDbContextModelSnapshot.cs @@ -266,6 +266,16 @@ namespace TelegramPanel.Data.Migrations b.Property("CategoryId") .HasColumnType("INTEGER"); + b.Property("ChannelStatusCheckedAtUtc") + .HasColumnType("TEXT"); + + b.Property("ChannelStatusError") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ChannelStatusOk") + .HasColumnType("INTEGER"); + b.Property("CreatedAt") .HasColumnType("TEXT"); @@ -294,6 +304,8 @@ namespace TelegramPanel.Data.Migrations b.HasIndex("CategoryId"); + b.HasIndex("ChannelStatusOk"); + b.HasIndex("TelegramId") .IsUnique(); diff --git a/src/TelegramPanel.Data/Repositories/BotChannelRepository.cs b/src/TelegramPanel.Data/Repositories/BotChannelRepository.cs index e54858d..a7f9d64 100644 --- a/src/TelegramPanel.Data/Repositories/BotChannelRepository.cs +++ b/src/TelegramPanel.Data/Repositories/BotChannelRepository.cs @@ -9,7 +9,7 @@ public class BotChannelRepository : Repository, IBotChannelRepositor { } - private IQueryable BuildQuery(int botId, int? categoryId, bool broadcastOnly, string? search) + private IQueryable BuildQuery(int botId, int? categoryId, bool broadcastOnly, string? search, int statusFilter) { // 兼容旧模块/旧 UI 约定:categoryId=0 表示“不筛选分类” if (categoryId == 0) @@ -32,6 +32,16 @@ public class BotChannelRepository : Repository, IBotChannelRepositor if (broadcastOnly) query = query.Where(x => x.IsBroadcast); + // 频道状态筛选: + // 0=全部,1=正常,2=异常,3=未检测 + query = statusFilter switch + { + 1 => query.Where(x => x.ChannelStatusOk == true), + 2 => query.Where(x => x.ChannelStatusOk == false), + 3 => query.Where(x => x.ChannelStatusOk == null), + _ => query + }; + search = (search ?? string.Empty).Trim(); if (!string.IsNullOrWhiteSpace(search)) { @@ -87,6 +97,7 @@ public class BotChannelRepository : Repository, IBotChannelRepositor int? categoryId, bool broadcastOnly, string? search, + int statusFilter, int pageIndex, int pageSize, CancellationToken cancellationToken = default) @@ -95,7 +106,7 @@ public class BotChannelRepository : Repository, IBotChannelRepositor if (pageSize <= 0) pageSize = 20; if (pageSize > 500) pageSize = 500; - var query = BuildQuery(botId, categoryId, broadcastOnly, search); + var query = BuildQuery(botId, categoryId, broadcastOnly, search, statusFilter); var total = await query.CountAsync(cancellationToken); var items = await query .Skip(pageIndex * pageSize) diff --git a/src/TelegramPanel.Data/Repositories/IBotChannelRepository.cs b/src/TelegramPanel.Data/Repositories/IBotChannelRepository.cs index af766da..78473c3 100644 --- a/src/TelegramPanel.Data/Repositories/IBotChannelRepository.cs +++ b/src/TelegramPanel.Data/Repositories/IBotChannelRepository.cs @@ -13,6 +13,7 @@ public interface IBotChannelRepository : IRepository int? categoryId, bool broadcastOnly, string? search, + int statusFilter, int pageIndex, int pageSize, CancellationToken cancellationToken = default); diff --git a/src/TelegramPanel.Web/Components/Pages/BotChannelsHome.razor b/src/TelegramPanel.Web/Components/Pages/BotChannelsHome.razor index 856fc95..269d10c 100644 --- a/src/TelegramPanel.Web/Components/Pages/BotChannelsHome.razor +++ b/src/TelegramPanel.Web/Components/Pages/BotChannelsHome.razor @@ -35,6 +35,15 @@ } + + 全部状态 + 正常 + 异常 + 未检测 + + @@ -72,12 +81,14 @@ + 检测状态(已选) 导出邀请(已选) 批量邀请成员(已选) 批量设置管理员(账号执行) 批量设置管理员(机器人/ID) 批量设置分类(已选) 踢出用户(已选) + 删除频道(已选) @if (selected.Count > 0) @@ -99,6 +110,7 @@ 频道名称 用户名 分类 + 频道状态 最后同步 操作 @@ -126,15 +138,26 @@ 未分类 } + + + + @GetChannelStatusText(context) + + + + + @@ -172,6 +195,7 @@ private HashSet selected = new(); private int filterCategoryId = 0; + private int filterStatus = 0; private string search = ""; private bool _actionsExpanded = true; private CancellationTokenSource? _searchDebounceCts; @@ -227,6 +251,7 @@ selectedBotId = botId; filterCategoryId = 0; + filterStatus = 0; search = ""; selected.Clear(); @@ -285,6 +310,7 @@ botId: selectedBotId, categoryId: filterCategoryId == -1 ? -1 : (filterCategoryId > 0 ? filterCategoryId : null), search: search, + statusFilter: filterStatus, pageIndex: state.Page, pageSize: state.PageSize, cancellationToken: cancellationToken); @@ -367,6 +393,182 @@ } } + private async Task CheckSingleChannelStatus(BotChannel channel) + { + if (selectedBotId <= 0) + return; + + loading = true; + try + { + var result = await BotTelegram.CheckChannelsStatusAsync( + selectedBotId, + new List { channel.TelegramId }, + CancellationToken.None); + + if (result.FailedCount == 0) + { + Snackbar.Add($"频道“{channel.Title}”检测正常", Severity.Success); + } + else + { + var msg = result.Failures.TryGetValue(channel.TelegramId, out var err) ? err : "检测失败"; + Snackbar.Add($"频道“{channel.Title}”检测异常:{msg}", Severity.Warning); + } + + await ReloadTable(); + } + catch (Exception ex) + { + Snackbar.Add($"检测失败:{ex.Message}", Severity.Error); + } + finally + { + loading = false; + } + } + + private async Task CheckSelectedChannelsStatus() + { + if (selectedBotId <= 0) + return; + + if (selected.Count == 0) + { + Snackbar.Add("请先选择频道", Severity.Info); + return; + } + + bool? ok = await DialogService.ShowMessageBox( + "确认检测", + $"将检测 {selected.Count} 个频道的状态(发送一条探测消息并立即删除)。是否继续?", + yesText: "继续", cancelText: "取消"); + + if (ok != true) + return; + + loading = true; + try + { + var ids = selected.Select(x => x.TelegramId).Distinct().ToList(); + var result = await BotTelegram.CheckChannelsStatusAsync(selectedBotId, ids, CancellationToken.None); + + if (result.FailedCount == 0) + { + Snackbar.Add($"检测完成:{result.SuccessCount} 个频道均正常", Severity.Success); + } + else + { + var first = result.Failures.FirstOrDefault(); + var msg = $"检测完成:正常 {result.SuccessCount} 个,异常 {result.FailedCount} 个"; + if (first.Key != 0) + msg += $"(首个失败:{first.Value})"; + Snackbar.Add(msg, Severity.Warning); + } + + await ReloadTable(); + } + catch (Exception ex) + { + Snackbar.Add($"检测失败:{ex.Message}", Severity.Error); + } + finally + { + loading = false; + } + } + + private async Task DeleteSingleChannelAsync(BotChannel channel) + { + if (selectedBotId <= 0) + return; + + var channelName = string.IsNullOrWhiteSpace(channel.Title) ? channel.TelegramId.ToString() : channel.Title; + bool? ok = await DialogService.ShowMessageBox( + "确认删除", + $"确定删除频道“{channelName}”吗?此操作只会移除面板中的 Bot 频道记录。", + yesText: "删除", cancelText: "取消"); + + if (ok != true) + return; + + loading = true; + try + { + await BotManagement.DeleteChannelByTelegramIdAsync(selectedBotId, channel.TelegramId); + Snackbar.Add($"已删除频道:{channelName}", Severity.Success); + await ReloadTable(); + } + catch (Exception ex) + { + Snackbar.Add($"删除失败:{ex.Message}", Severity.Error); + } + finally + { + loading = false; + } + } + + private async Task DeleteSelectedChannelsAsync() + { + if (selectedBotId <= 0) + return; + + if (selected.Count == 0) + { + Snackbar.Add("请先选择频道", Severity.Info); + return; + } + + bool? ok = await DialogService.ShowMessageBox( + "确认批量删除", + $"确定删除已选 {selected.Count} 个频道吗?此操作只会移除面板中的 Bot 频道记录。", + yesText: "删除", cancelText: "取消"); + + if (ok != true) + return; + + loading = true; + try + { + var ids = selected.Select(x => x.TelegramId).Distinct().ToList(); + var success = 0; + var failed = 0; + + foreach (var chatId in ids) + { + try + { + await BotManagement.DeleteChannelByTelegramIdAsync(selectedBotId, chatId); + success++; + } + catch + { + failed++; + } + } + + if (failed == 0) + { + Snackbar.Add($"批量删除完成:成功 {success} 个频道", Severity.Success); + } + else + { + Snackbar.Add($"批量删除完成:成功 {success} 个,失败 {failed} 个", Severity.Warning); + } + + await ReloadTable(); + } + catch (Exception ex) + { + Snackbar.Add($"批量删除失败:{ex.Message}", Severity.Error); + } + finally + { + loading = false; + } + } + private async Task OpenCreateCategoryDialog() { if (selectedBotId <= 0) @@ -741,6 +943,39 @@ await LoadBotData(); } + private static string GetChannelStatusText(BotChannel channel) + { + return channel.ChannelStatusOk switch + { + true => "正常", + false => "异常", + null => "未检测" + }; + } + + private static Color GetChannelStatusColor(BotChannel channel) + { + return channel.ChannelStatusOk switch + { + true => Color.Success, + false => Color.Error, + null => Color.Default + }; + } + + private static string GetChannelStatusTooltip(BotChannel channel) + { + if (channel.ChannelStatusOk == null) + return "尚未检测"; + + var checkedAt = channel.ChannelStatusCheckedAtUtc?.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss") ?? "-"; + if (channel.ChannelStatusOk == true) + return $"最近检测:{checkedAt}"; + + var err = string.IsNullOrWhiteSpace(channel.ChannelStatusError) ? "未知错误" : channel.ChannelStatusError; + return $"最近检测:{checkedAt};失败原因:{err}"; + } + private Task SelectAllFiltered() { selected = channels.ToHashSet();