feat: 完整支持 tdata 压缩包导入并修复会话校验链路

- 导入链路新增 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 前置要求
This commit is contained in:
meoacgx committed 2026-03-02 23:07:52 +08:00
1 parent 7cac80f528
commit 7997b25fc6
7 files changed
+607 -13

No files matched your search

+11
View File
@@ -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"]
+11 -1
View File
@@ -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"]
@@ -15,7 +15,8 @@ public interface ISessionImporter
int apiId,
string apiHash,
long? userId = null,
string? phoneHint = null);
string? phoneHint = null,
string? sessionKey = null);
/// <summary>
/// 批量导入Session文件
@@ -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<List<ImportResult>> ImportFromTdataDirectoriesAsync(
IReadOnlyCollection<string> tdataDirectories,
int? categoryId,
string? twoFactorPassword)
{
var results = new List<ImportResult>();
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<string>(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<ImportResult> 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<ImportResult> 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<string> FindTdataDirectories(string rootDirectory)
{
var result = new List<string>();
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;
}
}
/// <summary>
/// 尝试从目录中读取 2fa.txt 文件内容作为二级密码
/// </summary>
@@ -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<User>().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<User>().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
);
}
@@ -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<ConvertResult> 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<ProcessResult> RunProcessAsync(
string fileName,
IReadOnlyCollection<string> 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());
}
}
@@ -30,8 +30,10 @@
<ul style="margin: 6px 0 0 18px;">
<li>单账号:Zip 根目录直接包含一个 <strong>.json</strong> + 一个 <strong>.session</strong></li>
<li>批量导入:每个账号一个独立子文件夹,文件夹内包含一个 <strong>.json</strong> + 一个 <strong>.session</strong></li>
<li><strong>tdata 协议包</strong>:支持 Zip 内包含 <strong>tdata</strong> 目录(含 key_datas / D877F783D5D3EF8C*);首次导入会自动安装解析依赖</li>
<li><strong>二级密码</strong>:自动解析账号目录下的 <strong>2fa.txt</strong> 文件作为二级密码保存到数据库</li>
</ul>
<div style="margin-top: 6px;">提示:导入 tdata 需要先在【系统设置】配置全局 Telegram API(ApiId/ApiHash)。</div>
<div style="margin-top: 6px;">批量结构示例:</div>
<pre style="margin: 4px 0 0; white-space: pre; overflow: auto;">xx.zip
├─ 8613111111111