From 7997b25fc661656ef4cf910cfe61cea6e40cb82b Mon Sep 17 00:00:00 2001 From: meoacgx Date: Mon, 2 Mar 2026 23:07:52 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E6=95=B4=E6=94=AF=E6=8C=81=20?= =?UTF-8?q?tdata=20=E5=8E=8B=E7=BC=A9=E5=8C=85=E5=AF=BC=E5=85=A5=E5=B9=B6?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=BC=9A=E8=AF=9D=E6=A0=A1=E9=AA=8C=E9=93=BE?= =?UTF-8?q?=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