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:
1 parent
7cac80f528
commit
7997b25fc6
7 files changed
+607
-13
No files matched your search
+11
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user