merge(main): 合并 dev 分支已发布的 tdata 导入与相关改进

This commit is contained in:
meoacgx committed 2026-03-02 23:53:59 +08:00
commit cd8a2bba50
19 files changed
+2135 -201

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>
@@ -824,7 +824,8 @@ public sealed record BotAdminRights(
IReadOnlyList<long> channelTelegramIds,
long userId,
bool permanentBan,
CancellationToken cancellationToken)
CancellationToken cancellationToken,
Func<long, string?, int, int, Task>? perChatCallback = null)
{
var bot = await _botManagement.GetBotAsync(botId)
?? throw new InvalidOperationException($"机器人不存在:{botId}");
@@ -936,6 +937,12 @@ public sealed record BotAdminRights(
}
}
if (perChatCallback != null)
{
failures.TryGetValue(chatId, out var err);
await perChatCallback(chatId, err, ok, failures.Count);
}
// 降速:避免 Bot API 429
if (delayMs > 0 && index < distinctIds.Count - 1)
{
@@ -28,7 +28,12 @@ public sealed class BotUpdateHub : IAsyncDisposable
private readonly Dictionary<string, BotPoller> _pollersByToken = new(StringComparer.Ordinal);
// Webhook 模式下的接收器(token -> receiver),不启动轮询
private readonly Dictionary<string, BotWebhookReceiver> _webhookReceivers = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, BotWebhookReceiver> _webhookReceivers = new(StringComparer.Ordinal);
private static readonly TimeSpan WebhookTokenCacheTtl = TimeSpan.FromMinutes(5);
private readonly SemaphoreSlim _webhookTokenCacheGate = new(1, 1);
private DateTimeOffset _webhookTokenCacheBuiltAtUtc = DateTimeOffset.MinValue;
private Dictionary<string, string> _botTokenByWebhookPathToken = new(StringComparer.Ordinal);
public BotUpdateHub(
IServiceScopeFactory scopeFactory,
@@ -57,10 +62,19 @@ public sealed class BotUpdateHub : IAsyncDisposable
if (string.IsNullOrWhiteSpace(token))
return false;
if (_webhookReceivers.TryGetValue(token, out var fastReceiver))
{
fastReceiver.Inject(update);
return true;
}
BotWebhookReceiver receiver;
// 仅在“首次遇到某个 token”时加全局锁,避免高频 webhook 进入串行瓶颈。
await _gate.WaitAsync(cancellationToken);
try
{
if (!_webhookReceivers.TryGetValue(token, out var receiver))
if (!_webhookReceivers.TryGetValue(token, out receiver!))
{
// 验证 token 有效性并创建 receiver
using var scope = _scopeFactory.CreateScope();
@@ -72,14 +86,76 @@ public sealed class BotUpdateHub : IAsyncDisposable
receiver = new BotWebhookReceiver(bot.Id, token, _scopeFactory, _logger);
_webhookReceivers[token] = receiver;
}
receiver.Inject(update);
return true;
}
finally
{
_gate.Release();
}
receiver.Inject(update);
return true;
}
/// <summary>
/// 将 Webhook 路径中的 token(推荐为 SHA256(token))解析为真实的 bot token。
/// </summary>
public async Task<string?> ResolveBotTokenFromWebhookPathAsync(string pathToken, CancellationToken cancellationToken)
{
pathToken = (pathToken ?? "").Trim();
if (string.IsNullOrWhiteSpace(pathToken))
return null;
// 兼容:若仍使用明文 bot token 作为路径(不推荐),直接返回。
if (WebhookTokenHelper.IsLikelyPlainBotToken(pathToken))
return pathToken;
if (!WebhookTokenHelper.IsSha256Hex(pathToken))
return null;
var builtAt = _webhookTokenCacheBuiltAtUtc;
if (builtAt != DateTimeOffset.MinValue
&& DateTimeOffset.UtcNow - builtAt < WebhookTokenCacheTtl
&& _botTokenByWebhookPathToken.TryGetValue(pathToken, out var cached))
{
return cached;
}
await _webhookTokenCacheGate.WaitAsync(cancellationToken);
try
{
builtAt = _webhookTokenCacheBuiltAtUtc;
if (builtAt == DateTimeOffset.MinValue || DateTimeOffset.UtcNow - builtAt >= WebhookTokenCacheTtl)
await RebuildWebhookTokenCacheAsync(cancellationToken);
return _botTokenByWebhookPathToken.TryGetValue(pathToken, out var token) ? token : null;
}
finally
{
_webhookTokenCacheGate.Release();
}
}
private async Task RebuildWebhookTokenCacheAsync(CancellationToken cancellationToken)
{
using var scope = _scopeFactory.CreateScope();
var botRepo = scope.ServiceProvider.GetRequiredService<IBotRepository>();
var bots = await botRepo.GetAllAsync();
var map = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var bot in bots)
{
if (!bot.IsActive || string.IsNullOrWhiteSpace(bot.Token))
continue;
var token = bot.Token.Trim();
if (string.IsNullOrWhiteSpace(token))
continue;
map[WebhookTokenHelper.ToWebhookPathToken(token)] = token;
}
_botTokenByWebhookPathToken = map;
_webhookTokenCacheBuiltAtUtc = DateTimeOffset.UtcNow;
}
/// <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());
}
}
@@ -0,0 +1,48 @@
using System.Security.Cryptography;
using System.Text;
namespace TelegramPanel.Core.Services.Telegram;
public static class WebhookTokenHelper
{
public static string ToWebhookPathToken(string botToken)
{
botToken = (botToken ?? "").Trim();
if (string.IsNullOrWhiteSpace(botToken))
throw new ArgumentException("botToken 不能为空", nameof(botToken));
var bytes = Encoding.UTF8.GetBytes(botToken);
var hash = SHA256.HashData(bytes);
return Convert.ToHexString(hash).ToLowerInvariant();
}
public static bool IsLikelyPlainBotToken(string value)
{
value = (value ?? "").Trim();
if (value.Length == 0)
return false;
// Telegram bot token 典型格式:{bot_id}:{token}
return value.Contains(':', StringComparison.Ordinal);
}
public static bool IsSha256Hex(string value)
{
value = (value ?? "").Trim();
if (value.Length != 64)
return false;
foreach (var ch in value)
{
var isHex =
(ch >= '0' && ch <= '9') ||
(ch >= 'a' && ch <= 'f') ||
(ch >= 'A' && ch <= 'F');
if (!isHex)
return false;
}
return true;
}
}
@@ -0,0 +1,365 @@
@namespace TelegramPanel.Web.Components.ModulePages
@inject TelegramPanel.Core.Services.BotManagementService BotManagement
@inject TelegramPanel.Core.Services.BatchTaskManagementService TaskManagement
@inject ISnackbar Snackbar
@inject NavigationManager Navigation
@using System.Text.Json
@using TelegramPanel.Core.BatchTasks
@using TelegramPanel.Data.Entities
@using TelegramPanel.Web.ExternalApi
@using Microsoft.AspNetCore.Components
<MudText Typo="Typo.h4" Class="mb-4 tp-page-title">踢人/封禁</MudText>
<MudPaper Class="pa-4" Elevation="0" Outlined="true">
<MudStack Spacing="2">
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Dense="true">
提交后会在后台执行,可到【任务中心】查看进度与结果。频道数量较多时可能需要较长时间。
</MudAlert>
<MudSelect T="int" Value="@selectedBotId" ValueChanged="@(async (int id) => await OnSelectedBotChanged(id))"
Label="选择机器人" Variant="Variant.Outlined" Dense="true" Disabled="@loading">
<MudSelectItem Value="0">全部机器人(谨慎)</MudSelectItem>
@foreach (var b in bots)
{
<MudSelectItem Value="@b.Id">@b.Name</MudSelectItem>
}
</MudSelect>
<MudTextField @bind-Value="userIdsText" @bind-Value:after="OnUserIdsChanged"
Label="用户 ID(每行一个,可多行)" Variant="Variant.Outlined" Lines="3"
HelperText="支持逗号/空格/换行分隔"
Immediate="true"
Placeholder="例如:123456789&#10;987654321"
Disabled="@loading" />
<MudText Typo="Typo.caption" Class="mud-text-secondary">已解析 @userIds.Count 个用户 ID</MudText>
<MudSwitch @bind-Value="permanentBan" Color="Color.Error" Disabled="@loading"
Label="永久封禁(否则仅踢出,可重新加入)" />
<MudSwitch @bind-Value="useAllChats" @bind-Value:after="OnUseAllChatsChanged" Disabled="@(loading || selectedBotId == 0)"
Label="使用全部频道/群组(关闭后可按分类筛选或输入 chat_id)" />
@if (selectedBotId != 0 && !useAllChats)
{
<MudStack direction="Row" AlignItems="AlignItems.Center" Spacing="2">
<MudText Typo="Typo.subtitle2">选择分类:@selectedCategories.Count / @categories.Count</MudText>
<MudSpacer />
<MudButton Variant="Variant.Outlined" Color="Color.Primary" OnClick="SelectAllCategories"
Disabled="@(loading || categories.Count == 0)">
全选
</MudButton>
<MudButton Variant="Variant.Text" Color="Color.Secondary" OnClick="ClearCategories"
Disabled="@(loading || selectedCategories.Count == 0)">
清空
</MudButton>
</MudStack>
<MudTable Items="@categories" Dense="true" Hover="true" Breakpoint="Breakpoint.Sm"
MultiSelection="true" @bind-SelectedItems="selectedCategories">
<HeaderContent>
<MudTh>分类</MudTh>
<MudTh>说明</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="分类">@context.Name</MudTd>
<MudTd DataLabel="说明">@(!string.IsNullOrWhiteSpace(context.Description) ? context.Description : "-")</MudTd>
</RowTemplate>
<NoRecordsContent>
<MudText>暂无分类</MudText>
</NoRecordsContent>
</MudTable>
<MudSwitch @bind-Value="includeUncategorized" Disabled="@loading" Label="包含未分类" />
<MudTextField @bind-Value="chatIdsText" Label="chat_id 列表(可选)" Variant="Variant.Outlined" Lines="3"
Placeholder="支持逗号/空格/换行分隔,例如:123,456,789"
Immediate="true"
Disabled="@loading" />
}
<MudStack Row="true" Spacing="2">
<MudButton Variant="Variant.Filled" Color="Color.Error" StartIcon="@Icons.Material.Filled.PersonRemove"
Disabled="@(!CanSubmit)" OnClick="SubmitAsync">
提交任务
</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Default" Disabled="@loading"
OnClick="@(() => Navigation.NavigateTo("/tasks"))">
打开任务中心
</MudButton>
</MudStack>
</MudStack>
</MudPaper>
@code
{
[Parameter] public string ModuleId { get; set; } = "";
[Parameter] public string PageKey { get; set; } = "";
private bool loading = true;
private List<Bot> bots = new();
private List<BotChannelCategory> categories = new();
private int selectedBotId = 0;
private string userIdsText = "";
private List<long> userIds = new();
private bool permanentBan;
private bool useAllChats = true;
private HashSet<BotChannelCategory> selectedCategories = new();
private bool includeUncategorized;
private string chatIdsText = "";
private bool CanSubmit
=> !loading
&& userIds.Count > 0
&& (selectedBotId == 0 || bots.Any(x => x.Id == selectedBotId))
&& (
selectedBotId == 0
|| useAllChats
|| includeUncategorized
|| selectedCategories.Count > 0
|| !string.IsNullOrWhiteSpace(chatIdsText));
protected override async Task OnInitializedAsync()
{
try
{
bots = (await BotManagement.GetAllBotsAsync())
.Where(b => b.IsActive)
.OrderBy(b => b.Id)
.ToList();
categories = (await BotManagement.GetCategoriesAsync())
.OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
// 默认不对“全部机器人/全部频道”开火
if (bots.Count > 0)
{
selectedBotId = bots[0].Id;
useAllChats = false;
}
}
catch (Exception ex)
{
Snackbar.Add($"加载机器人失败:{ex.Message}", Severity.Error);
}
finally
{
loading = false;
}
}
private async Task OnUserIdsChanged()
{
userIds = ParseLongIds(userIdsText);
await Task.CompletedTask;
}
private async Task OnSelectedBotChanged(int id)
{
var previous = selectedBotId;
selectedBotId = id;
if (selectedBotId == 0)
useAllChats = true;
else if (previous == 0)
useAllChats = false;
selectedCategories = new HashSet<BotChannelCategory>();
includeUncategorized = false;
chatIdsText = "";
await Task.CompletedTask;
}
private async Task OnUseAllChatsChanged()
{
if (useAllChats)
{
selectedCategories = new HashSet<BotChannelCategory>();
includeUncategorized = false;
chatIdsText = "";
}
await Task.CompletedTask;
}
private async Task SelectAllCategories()
{
selectedCategories = categories.ToHashSet();
await Task.CompletedTask;
}
private async Task ClearCategories()
{
selectedCategories = new HashSet<BotChannelCategory>();
includeUncategorized = false;
await Task.CompletedTask;
}
private async Task SubmitAsync()
{
if (!CanSubmit)
return;
var botId = selectedBotId;
var runUseAllChats = botId == 0 ? true : useAllChats;
loading = true;
try
{
var total = 0;
var targetChatIds = new List<long>();
if (botId == 0)
{
foreach (var b in bots)
total += (await BotManagement.GetChatsAsync(b.Id)).Count();
}
else
{
var chats = (await BotManagement.GetChatsAsync(botId)).ToList();
if (runUseAllChats)
{
total = chats.Count;
}
else
{
var selectedIds = new HashSet<long>();
var selectedCategoryIdSet = selectedCategories.Select(x => x.Id).ToHashSet();
if (selectedCategoryIdSet.Count > 0 || includeUncategorized)
{
foreach (var c in chats)
{
if (c.CategoryId.HasValue)
{
if (selectedCategoryIdSet.Contains(c.CategoryId.Value))
selectedIds.Add(c.TelegramId);
}
else
{
if (includeUncategorized)
selectedIds.Add(c.TelegramId);
}
}
}
foreach (var id in ParseChatIds(chatIdsText))
selectedIds.Add(id);
if (selectedIds.Count > 0)
{
total = chats.Count(c => selectedIds.Contains(c.TelegramId));
var knownIds = chats.Select(c => c.TelegramId).ToHashSet();
targetChatIds = selectedIds
.Where(knownIds.Contains)
.OrderBy(x => x)
.ToList();
}
}
}
if (total <= 0)
{
Snackbar.Add("未匹配到任何可操作的频道/群组(请先同步 Bot 频道数据,或检查 chat_id 列表)", Severity.Warning);
return;
}
var createdIds = new List<int>();
var failedCount = 0;
string? firstError = null;
foreach (var uid in userIds)
{
try
{
var log = new KickTaskLog
{
ApiName = $"模块页面 (user={uid})",
BotId = botId,
UseAllChats = runUseAllChats,
ChatIds = runUseAllChats ? new List<long>() : targetChatIds,
UserId = uid,
PermanentBan = permanentBan,
RequestedAtUtc = DateTime.UtcNow
};
var task = new BatchTask
{
TaskType = BatchTaskTypes.ExternalApiKick,
Total = total,
Completed = 0,
Failed = 0,
Config = JsonSerializer.Serialize(log, new JsonSerializerOptions { WriteIndented = true })
};
var created = await TaskManagement.CreateTaskAsync(task);
createdIds.Add(created.Id);
}
catch (Exception ex)
{
failedCount++;
firstError ??= ex.Message;
}
}
if (createdIds.Count == 0)
{
Snackbar.Add($"提交失败:{firstError ?? "未知错误"}", Severity.Error);
return;
}
if (failedCount > 0)
Snackbar.Add($"已提交 {createdIds.Count} 个任务,{failedCount} 个失败(首个错误:{firstError})", Severity.Warning);
else
Snackbar.Add($"已提交 {createdIds.Count} 个任务。请到【任务中心】查看进度。", Severity.Success);
Navigation.NavigateTo("/tasks");
}
catch (Exception ex)
{
Snackbar.Add($"提交失败:{ex.Message}", Severity.Error);
}
finally
{
loading = false;
}
}
private static List<long> ParseChatIds(string raw)
{
raw = (raw ?? "").Trim();
if (raw.Length == 0)
return new List<long>();
var parts = raw
.Replace("\r", "\n", StringComparison.Ordinal)
.Split(new[] { ',', ';', ' ', '\n', '\t' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var ids = new List<long>();
foreach (var p in parts)
{
if (long.TryParse(p, out var v) && v != 0)
ids.Add(v);
}
return ids;
}
private static List<long> ParseLongIds(string raw)
{
raw = (raw ?? "").Trim();
if (raw.Length == 0)
return new List<long>();
var parts = raw
.Replace("\r", "\n", StringComparison.Ordinal)
.Split(new[] { ',', ';', ' ', '\n', '\t' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var set = new HashSet<long>();
foreach (var p in parts)
{
if (long.TryParse(p, out var v) && v > 0)
set.Add(v);
}
return set.OrderBy(x => x).ToList();
}
}
@@ -1,7 +1,17 @@
@page "/accounts/import"
@inject AccountImportService AccountImportService
@inject AccountManagementService AccountManagement
@inject AccountCategoryManagementService CategoryManagement
@inject AccountTelegramToolsService AccountTelegramTools
@inject TelegramPanel.Core.Services.AccountRiskService RiskService
@inject IConfiguration Configuration
@inject ISnackbar Snackbar
@inject IDialogService DialogService
@using TelegramPanel.Core.Models
@using TelegramPanel.Core.Utils
@using TelegramPanel.Data.Entities
@using TelegramPanel.Web.Components.Dialogs
@implements IDisposable
<PageTitle>导入账号 - Telegram Panel</PageTitle>
@@ -20,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
@@ -168,6 +180,82 @@
</MudCard>
}
@if (importedAccounts.Any())
{
<MudCard Class="mt-4">
<MudCardHeader>
<MudText Typo="Typo.h6">已导入账号(可批量操作)</MudText>
<MudSpacer />
<MudButton Variant="Variant.Text" Color="Color.Default" Disabled="@Busy" OnClick="@ClearImportedAccounts">
清空
</MudButton>
</MudCardHeader>
<MudCardContent>
<MudStack Row="true" Spacing="2" AlignItems="AlignItems.Center" Class="mb-2" Style="flex-wrap: wrap;">
<MudButton Variant="Variant.Outlined" Color="Color.Primary" StartIcon="@SelectionToggleIcon"
Disabled="@(Busy || importedAccounts.Count == 0)" OnClick="CycleSelectionToggle" Size="Size.Small">
@SelectionToggleText
</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Info" StartIcon="@Icons.Material.Filled.Sync"
Disabled="@(Busy || selectedAccounts.Count == 0)" OnClick="BatchRefreshTelegramStatus" Size="Size.Small">
刷新已选状态(可深度)
</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Warning" StartIcon="@Icons.Material.Filled.Devices"
Disabled="@(Busy || selectedAccounts.Count == 0)" OnClick="BatchKickAllOtherDevices" Size="Size.Small">
踢出其他设备(已选)
</MudButton>
<MudMenu Label="批量操作" Variant="Variant.Outlined" Color="Color.Default" StartIcon="@Icons.Material.Filled.MoreHoriz"
Size="Size.Small" Disabled="@(Busy || selectedAccounts.Count == 0)" Dense="true" EndIcon="@Icons.Material.Filled.KeyboardArrowDown">
<MudMenuItem OnClick="OpenBatchJoinSubscribe">批量加群/订阅(已选)</MudMenuItem>
<MudMenuItem OnClick="OpenBatchLeaveUnsubscribe">批量退群/退订(已选)</MudMenuItem>
<MudMenuItem OnClick="OpenBatchChangeTwoFactorPassword">修改二级密码(已选)</MudMenuItem>
<MudMenuItem OnClick="OpenBatchChangeTwoFactorRecoveryEmail">批量换绑邮箱(找回+登录)(Cloud Mail)(已选)</MudMenuItem>
<MudMenuItem OnClick="OpenBatchSetAccountCategory">批量修改分类(已选)</MudMenuItem>
<MudMenuItem OnClick="OpenBatchChangeNickname">批量改昵称(已选)</MudMenuItem>
<MudMenuItem OnClick="OpenBatchChangeUsername">批量改用户名(已选)</MudMenuItem>
<MudMenuItem OnClick="OpenBatchChangeBio">批量改Bio(已选)</MudMenuItem>
</MudMenu>
@if (selectedAccounts.Count > 0)
{
<MudChip T="string" Size="Size.Small" Color="Color.Info" Class="ml-2">已选 @selectedAccounts.Count</MudChip>
}
else
{
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="ml-2">共 @importedAccounts.Count 个账号</MudText>
}
</MudStack>
<MudTable Items="@importedAccounts" Hover="true" MultiSelection="true" @bind-SelectedItems="selectedAccounts" Dense="true">
<HeaderContent>
<MudTh>手机号</MudTh>
<MudTh>用户ID</MudTh>
<MudTh>用户名</MudTh>
<MudTh>分类</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="手机号">@context.DisplayPhone</MudTd>
<MudTd DataLabel="用户ID">@context.UserId</MudTd>
<MudTd DataLabel="用户名">@(context.Username ?? "-")</MudTd>
<MudTd DataLabel="分类">
@if (context.Category != null)
{
<MudChip T="string" Size="Size.Small" Style="@($"background-color: {context.Category.Color ?? "#9E9E9E"}")">
@context.Category.Name
</MudChip>
}
else
{
<MudText Typo="Typo.body2" Color="Color.Default">未分类</MudText>
}
</MudTd>
</RowTemplate>
</MudTable>
</MudCardContent>
</MudCard>
}
@code {
private List<IBrowserFile> selectedFiles = new();
private bool importing = false;
@@ -181,6 +269,32 @@
private List<ImportResult> importResults = new();
private bool actionLoading = false;
private bool Busy => importing || importingZip || importingString || actionLoading;
private readonly HashSet<string> importedPhones = new(StringComparer.Ordinal);
private List<Account> importedAccounts = new();
private HashSet<Account> selectedAccounts = new();
private List<AccountCategory> categories = new();
private readonly Dictionary<int, TelegramAccountStatusResult> telegramStatus = new();
private readonly CancellationTokenSource _pageCts = new();
public void Dispose()
{
try
{
_pageCts.Cancel();
}
catch
{
// ignore
}
finally
{
_pageCts.Dispose();
}
}
private bool TryGetTelegramApi(out int apiId, out string apiHash)
{
apiHash = Configuration["Telegram:ApiHash"] ?? "";
@@ -215,6 +329,7 @@
var twoFactorPwd = string.IsNullOrWhiteSpace(zipTwoFactorPassword) ? null : zipTwoFactorPassword.Trim();
var results = await AccountImportService.ImportFromZipAsync(zipFile, twoFactorPassword: twoFactorPwd);
importResults.AddRange(results);
await MergeImportedFromResultsAsync(results);
var successCount = results.Count(r => r.Success);
var failureCount = results.Count - successCount;
@@ -257,6 +372,7 @@
);
importResults.AddRange(results);
await MergeImportedFromResultsAsync(results);
var successCount = results.Count(r => r.Success);
var failureCount = results.Count - successCount;
@@ -303,6 +419,7 @@
);
importResults.Add(result);
await MergeImportedFromResultsAsync(new[] { result });
if (result.Success)
{
@@ -323,4 +440,492 @@
importingString = false;
}
}
private async Task MergeImportedFromResultsAsync(IEnumerable<ImportResult> results)
{
var changed = false;
foreach (var r in results)
{
if (!r.Success)
continue;
if (string.IsNullOrWhiteSpace(r.Phone))
continue;
var digits = PhoneNumberFormatter.NormalizeToDigits(r.Phone);
if (string.IsNullOrWhiteSpace(digits))
continue;
if (importedPhones.Add(digits))
changed = true;
}
if (changed)
await ReloadImportedAccountsAsync();
}
private async Task ReloadImportedAccountsAsync()
{
var list = new List<Account>();
foreach (var phone in importedPhones)
{
var a = await AccountManagement.GetAccountByPhoneAsync(phone);
if (a != null)
list.Add(a);
}
importedAccounts = list
.Where(a => a.Id > 0)
.GroupBy(a => a.Id)
.Select(g => g.First())
.OrderByDescending(a => a.Id)
.ToList();
selectedAccounts.Clear();
StateHasChanged();
}
private Task ClearImportedAccounts()
{
importedPhones.Clear();
importedAccounts.Clear();
selectedAccounts.Clear();
telegramStatus.Clear();
return Task.CompletedTask;
}
private Task SelectAllImported()
{
selectedAccounts = importedAccounts.ToHashSet();
return Task.CompletedTask;
}
private Task InvertSelection()
{
var targets = importedAccounts.ToList();
foreach (var a in targets)
{
if (selectedAccounts.Contains(a))
selectedAccounts.Remove(a);
else
selectedAccounts.Add(a);
}
return Task.CompletedTask;
}
private Task ClearSelection()
{
selectedAccounts.Clear();
return Task.CompletedTask;
}
private SelectionToggleMode _selectionToggleMode = SelectionToggleMode.SelectAll;
private string SelectionToggleText => _selectionToggleMode switch
{
SelectionToggleMode.SelectAll => "全选本页",
SelectionToggleMode.Invert => "反选本页",
SelectionToggleMode.Clear => "清空选择",
_ => "全选本页"
};
private string SelectionToggleIcon => _selectionToggleMode switch
{
SelectionToggleMode.SelectAll => Icons.Material.Filled.SelectAll,
SelectionToggleMode.Invert => Icons.Material.Filled.Flip,
SelectionToggleMode.Clear => Icons.Material.Filled.Clear,
_ => Icons.Material.Filled.SelectAll
};
private async Task CycleSelectionToggle()
{
switch (_selectionToggleMode)
{
case SelectionToggleMode.SelectAll:
await SelectAllImported();
_selectionToggleMode = SelectionToggleMode.Invert;
break;
case SelectionToggleMode.Invert:
await InvertSelection();
_selectionToggleMode = SelectionToggleMode.Clear;
break;
case SelectionToggleMode.Clear:
await ClearSelection();
_selectionToggleMode = SelectionToggleMode.SelectAll;
break;
default:
_selectionToggleMode = SelectionToggleMode.SelectAll;
break;
}
}
private enum SelectionToggleMode
{
SelectAll = 0,
Invert = 1,
Clear = 2
}
private async Task LoadCategories()
{
categories = (await CategoryManagement.GetAllCategoriesAsync()).ToList();
}
private async Task OpenBatchChangeTwoFactorPassword()
{
if (Busy)
return;
var ids = selectedAccounts.Select(a => a.Id).Distinct().ToList();
if (ids.Count == 0)
{
Snackbar.Add("请先选择账号", Severity.Info);
return;
}
var parameters = new DialogParameters
{
["AccountIds"] = ids
};
var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Small, FullWidth = true };
DialogService.Show<TelegramPanel.Web.Components.Dialogs.ChangeTwoFactorPasswordDialog>("批量修改二级密码", parameters, options);
await Task.CompletedTask;
}
private async Task OpenBatchChangeTwoFactorRecoveryEmail()
{
if (Busy)
return;
var ids = selectedAccounts.Select(a => a.Id).Distinct().ToList();
if (ids.Count == 0)
{
Snackbar.Add("请先选择账号", Severity.Info);
return;
}
var parameters = new DialogParameters
{
["AccountIds"] = ids
};
var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Small, FullWidth = true };
DialogService.Show<TelegramPanel.Web.Components.Dialogs.BatchChangeTwoFactorRecoveryEmailDialog>("批量换绑邮箱(找回+登录)", parameters, options);
await Task.CompletedTask;
}
private async Task OpenBatchSetAccountCategory()
{
if (Busy)
return;
var targets = selectedAccounts.Select(a => a.Id).Distinct().ToList();
if (targets.Count == 0)
{
Snackbar.Add("请先选择账号", Severity.Info);
return;
}
if (categories.Count == 0)
await LoadCategories();
var parameters = new DialogParameters
{
["Categories"] = categories,
["SelectedCount"] = targets.Count
};
var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Small, FullWidth = true };
var dialog = DialogService.Show<TelegramPanel.Web.Components.Dialogs.BatchSetAccountCategoryDialog>("批量修改分类", parameters, options)!;
var result = await dialog.Result;
if (result is not { Canceled: false })
return;
var categoryIdRaw = result.Data is int x ? x : 0;
int? categoryId = categoryIdRaw <= 0 ? null : categoryIdRaw;
actionLoading = true;
try
{
foreach (var id in targets)
await AccountManagement.UpdateAccountCategoryAsync(id, categoryId);
await ReloadImportedAccountsAsync();
Snackbar.Add($"分类已更新:{targets.Count} 个账号", Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"修改分类失败:{ex.Message}", Severity.Error);
}
finally
{
actionLoading = false;
}
}
private async Task OpenBatchChangeNickname()
{
if (Busy)
return;
var ids = selectedAccounts.Select(a => a.Id).Distinct().ToList();
if (ids.Count == 0)
{
Snackbar.Add("请先选择账号", Severity.Info);
return;
}
var parameters = new DialogParameters
{
["AccountIds"] = ids
};
var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Medium, FullWidth = true };
var dialog = DialogService.Show<TelegramPanel.Web.Components.Dialogs.BatchChangeNicknameDialog>("批量修改昵称", parameters, options)!;
var result = await dialog.Result;
if (result is { Canceled: false })
await ReloadImportedAccountsAsync();
}
private async Task OpenBatchChangeUsername()
{
if (Busy)
return;
var ids = selectedAccounts.Select(a => a.Id).Distinct().ToList();
if (ids.Count == 0)
{
Snackbar.Add("请先选择账号", Severity.Info);
return;
}
var parameters = new DialogParameters
{
["AccountIds"] = ids
};
var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Medium, FullWidth = true };
var dialog = DialogService.Show<TelegramPanel.Web.Components.Dialogs.BatchChangeUsernameDialog>("批量修改用户名", parameters, options)!;
var result = await dialog.Result;
if (result is { Canceled: false })
await ReloadImportedAccountsAsync();
}
private async Task OpenBatchChangeBio()
{
if (Busy)
return;
var ids = selectedAccounts.Select(a => a.Id).Distinct().ToList();
if (ids.Count == 0)
{
Snackbar.Add("请先选择账号", Severity.Info);
return;
}
var parameters = new DialogParameters
{
["AccountIds"] = ids
};
var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Medium, FullWidth = true };
var dialog = DialogService.Show<TelegramPanel.Web.Components.Dialogs.BatchChangeBioDialog>("批量修改 Bio", parameters, options)!;
var result = await dialog.Result;
if (result is { Canceled: false })
await ReloadImportedAccountsAsync();
}
private async Task OpenBatchJoinSubscribe()
{
if (Busy)
return;
var ids = selectedAccounts.Select(a => a.Id).Distinct().ToList();
if (ids.Count == 0)
{
Snackbar.Add("请先选择账号", Severity.Info);
return;
}
var parameters = new DialogParameters
{
["AccountIds"] = ids,
["Operation"] = "join"
};
var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Medium, FullWidth = true };
DialogService.Show<TelegramPanel.Web.Components.Dialogs.BatchChatMembershipDialog>("批量加群/订阅", parameters, options);
await Task.CompletedTask;
}
private async Task OpenBatchLeaveUnsubscribe()
{
if (Busy)
return;
var ids = selectedAccounts.Select(a => a.Id).Distinct().ToList();
if (ids.Count == 0)
{
Snackbar.Add("请先选择账号", Severity.Info);
return;
}
var parameters = new DialogParameters
{
["AccountIds"] = ids,
["Operation"] = "leave"
};
var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Medium, FullWidth = true };
DialogService.Show<TelegramPanel.Web.Components.Dialogs.BatchChatMembershipDialog>("批量退群/退订", parameters, options);
await Task.CompletedTask;
}
private async Task BatchRefreshTelegramStatus()
{
var targets = selectedAccounts
.Where(a => a.Id > 0)
.GroupBy(a => a.Id)
.Select(g => g.First())
.ToList();
if (targets.Count == 0)
{
Snackbar.Add("请先选择账号", Severity.Warning);
return;
}
actionLoading = true;
try
{
bool? probeResult = await DialogService.ShowMessageBox(
"刷新已选 Telegram 状态",
$"将刷新已选 {targets.Count} 个账号的 Telegram 状态。\n\n是否进行深度探测(将对每个账号创建并删除一个测试频道,用于判断【创建频道接口是否被冻结】)?",
yesText: "深度探测",
cancelText: "普通刷新");
var probe = probeResult == true;
// 批量风控检查:深度探测是敏感操作
if (probe && targets.Count > 0)
{
var batchCheck = RiskService.CheckBatchAccounts(targets);
if (batchCheck.HasRiskyAccounts)
{
var parameters = new DialogParameters
{
["Message"] = $"检测到 {batchCheck.RiskyCount} 个风险账号",
["DetailedMessage"] = batchCheck.GetRiskySummary(),
["ShowRecommendations"] = true,
["ShowExcludeOption"] = true,
["RiskyAccounts"] = batchCheck.RiskyAccounts.ToList()
};
var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Large };
var dialog = DialogService.Show<RiskWarningDialog>("批量操作风控警告", parameters, options);
var dialogResult = await dialog.Result;
if (dialogResult.Canceled)
{
actionLoading = false;
return;
}
if (dialogResult.Data is TelegramPanel.Core.Services.RiskWarningAction action
&& action == TelegramPanel.Core.Services.RiskWarningAction.ExcludeRisky)
{
targets = batchCheck.SafeAccounts.ToList();
if (targets.Count == 0)
{
Snackbar.Add("排除风险账号后无剩余账号", Severity.Info);
actionLoading = false;
return;
}
}
}
}
foreach (var a in targets)
{
try
{
var result = await AccountTelegramTools.RefreshAccountStatusAsync(a.Id, probeCreateChannel: probe, cancellationToken: _pageCts.Token);
telegramStatus[a.Id] = result;
}
catch (Exception ex)
{
telegramStatus[a.Id] = new TelegramAccountStatusResult(
Ok: false,
Summary: "刷新失败",
Details: ex.Message,
CheckedAtUtc: DateTime.UtcNow);
}
StateHasChanged();
}
await ReloadImportedAccountsAsync();
Snackbar.Add($"批量刷新完成:{targets.Count} 个账号", Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"批量刷新失败:{ex.Message}", Severity.Error);
}
finally
{
actionLoading = false;
}
}
private async Task BatchKickAllOtherDevices()
{
if (actionLoading)
return;
var targets = selectedAccounts.ToList();
if (targets.Count == 0)
{
Snackbar.Add("请先选择账号", Severity.Info);
return;
}
bool? result = await DialogService.ShowMessageBox(
"确认踢出",
$"将对 {targets.Count} 个账号执行【踢出所有其他设备】(会保留面板当前会话)。是否继续?",
yesText: "继续", cancelText: "取消");
if (result != true)
return;
actionLoading = true;
try
{
var success = 0;
var failures = new List<string>();
foreach (var a in targets)
{
try
{
var ok = await AccountTelegramTools.KickAllOtherAuthorizationsAsync(a.Id);
if (ok) success++;
else failures.Add($"{a.DisplayPhone}:返回 false");
}
catch (Exception ex)
{
failures.Add($"{a.DisplayPhone}:{ex.Message}");
}
}
selectedAccounts.Clear();
if (failures.Count == 0)
{
Snackbar.Add($"踢出完成:成功 {success}/{targets.Count}", Severity.Success);
return;
}
Snackbar.Add($"踢出完成:成功 {success}/{targets.Count},失败 {failures.Count}", Severity.Warning);
var details = string.Join(Environment.NewLine, failures.Take(80));
if (failures.Count > 80)
details += Environment.NewLine + $"... 仅展示前 80 条(共 {failures.Count} 条)";
await DialogService.ShowMessageBox("失败明细(前 80 条)", details, "关闭");
}
finally
{
actionLoading = false;
}
}
}
@@ -275,7 +275,8 @@
try
{
var webhookUrl = $"{baseUrl.TrimEnd('/')}/api/bot/webhook/{Uri.EscapeDataString(token.Trim())}";
var pathToken = WebhookTokenHelper.ToWebhookPathToken(token.Trim());
var webhookUrl = $"{baseUrl.TrimEnd('/')}/api/bot/webhook/{pathToken}";
await BotApi.SetWebhookAsync(
token: token.Trim(),
url: webhookUrl,
@@ -209,7 +209,7 @@
启用日志输出
</MudSwitch>
<MudText Typo="Typo.caption" Color="Color.Secondary">
说明:关闭后会禁用 Serilog 输出(控制台/文件)。Docker 仍可能记录 stdout/stderr,建议同时在 docker-compose 配置日志滚动。
说明:关闭后仅输出 Error/Fatal(控制台),并停止写入日志文件。Docker 仍可能记录 stdout/stderr,建议同时在 docker-compose 配置日志滚动。
</MudText>
<MudSelect @bind-Value="logLevel" Label="日志级别" Variant="Variant.Outlined" Disabled="@(!logEnabled)"
@@ -0,0 +1,244 @@
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using TelegramPanel.Core.BatchTasks;
using TelegramPanel.Core.Services;
using TelegramPanel.Core.Services.Telegram;
using TelegramPanel.Data.Entities;
using TelegramPanel.Modules;
namespace TelegramPanel.Web.ExternalApi;
public sealed class ExternalApiKickTaskHandler : IModuleTaskHandler
{
public string TaskType => BatchTaskTypes.ExternalApiKick;
public async Task ExecuteAsync(IModuleTaskExecutionHost host, CancellationToken cancellationToken)
{
var logger = host.Services.GetRequiredService<ILogger<ExternalApiKickTaskHandler>>();
var taskManagement = host.Services.GetRequiredService<BatchTaskManagementService>();
var botManagement = host.Services.GetRequiredService<BotManagementService>();
var botTelegram = host.Services.GetRequiredService<BotTelegramService>();
var rawConfig = (host.Config ?? "").Trim();
if (rawConfig.Length == 0)
throw new InvalidOperationException("任务缺少 Config");
KickTaskLog? input;
try
{
input = JsonSerializer.Deserialize<KickTaskLog>(rawConfig);
}
catch (Exception ex)
{
throw new InvalidOperationException($"任务 Config JSON 无效:{ex.Message}");
}
if (input == null)
throw new InvalidOperationException("任务 Config JSON 为空");
if (input.UserId <= 0)
throw new InvalidOperationException("任务 user_id 无效");
input.ChatIds ??= new List<long>();
var configuredBotId = input.BotId;
var useAllChats = configuredBotId == 0 ? true : input.UseAllChats;
var chatIdSet = new HashSet<long>(input.ChatIds.Where(x => x != 0));
var targets = await ResolveTargetsAsync(botManagement, configuredBotId, useAllChats, chatIdSet, cancellationToken);
if (targets.TotalChats == 0)
throw new InvalidOperationException("未匹配到任何可操作的频道/群组");
var results = new List<KickResultItem>(targets.TotalChats);
var completed = 0;
var failed = 0;
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
var linkedToken = linkedCts.Token;
try
{
foreach (var group in targets.Groups)
{
linkedToken.ThrowIfCancellationRequested();
if (!await host.IsStillRunningAsync(linkedToken))
{
await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(new KickTaskLog
{
ApiName = input.ApiName ?? "",
BotId = configuredBotId,
UseAllChats = useAllChats,
ChatIds = chatIdSet.OrderBy(x => x).ToList(),
UserId = input.UserId,
PermanentBan = input.PermanentBan,
RequestedAtUtc = input.RequestedAtUtc,
Results = results,
Canceled = true
}));
return;
}
var baseCompleted = completed;
var baseFailed = failed;
var chatById = group.Chats.ToDictionary(c => c.TelegramId);
var recorded = new HashSet<long>();
try
{
_ = await botTelegram.BanChatMemberAsync(
botId: group.Bot.Id,
channelTelegramIds: group.Chats.Select(x => x.TelegramId).ToList(),
userId: input.UserId,
permanentBan: input.PermanentBan,
cancellationToken: linkedToken,
perChatCallback: async (chatId, error, okCount, failCount) =>
{
if (!recorded.Add(chatId))
return;
var ok = error == null;
results.Add(new KickResultItem(
ChatId: chatId.ToString(),
Title: chatById.TryGetValue(chatId, out var ch) ? ch.Title : chatId.ToString(),
Success: ok,
Error: error));
completed = baseCompleted + okCount;
failed = baseFailed + failCount;
await host.UpdateProgressAsync(completed, failed, linkedToken);
if (!await host.IsStillRunningAsync(linkedToken))
linkedCts.Cancel();
});
}
catch (OperationCanceledException) when (linkedCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested)
{
// 用户主动取消(把任务状态改为非 running)
await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(new KickTaskLog
{
ApiName = input.ApiName ?? "",
BotId = configuredBotId,
UseAllChats = useAllChats,
ChatIds = chatIdSet.OrderBy(x => x).ToList(),
UserId = input.UserId,
PermanentBan = input.PermanentBan,
RequestedAtUtc = input.RequestedAtUtc,
Results = results,
Canceled = true
}));
return;
}
catch (Exception ex)
{
logger.LogWarning(ex, "ExternalApiKick failed on bot {BotId} (chats={Count})", group.Bot.Id, group.Chats.Count);
foreach (var chat in group.Chats)
{
if (!recorded.Add(chat.TelegramId))
continue;
results.Add(new KickResultItem(
ChatId: chat.TelegramId.ToString(),
Title: chat.Title,
Success: false,
Error: ex.Message));
failed++;
}
await host.UpdateProgressAsync(completed, failed, linkedToken);
}
}
}
catch (OperationCanceledException) when (linkedCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested)
{
await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(new KickTaskLog
{
ApiName = input.ApiName ?? "",
BotId = configuredBotId,
UseAllChats = useAllChats,
ChatIds = chatIdSet.OrderBy(x => x).ToList(),
UserId = input.UserId,
PermanentBan = input.PermanentBan,
RequestedAtUtc = input.RequestedAtUtc,
Results = results,
Canceled = true
}));
return;
}
catch (Exception ex)
{
logger.LogWarning(ex, "ExternalApiKick failed (taskId={TaskId})", host.TaskId);
await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(new KickTaskLog
{
ApiName = input.ApiName ?? "",
BotId = configuredBotId,
UseAllChats = useAllChats,
ChatIds = chatIdSet.OrderBy(x => x).ToList(),
UserId = input.UserId,
PermanentBan = input.PermanentBan,
RequestedAtUtc = input.RequestedAtUtc,
Results = results,
Error = ex.Message
}));
throw;
}
await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(new KickTaskLog
{
ApiName = input.ApiName ?? "",
BotId = configuredBotId,
UseAllChats = useAllChats,
ChatIds = chatIdSet.OrderBy(x => x).ToList(),
UserId = input.UserId,
PermanentBan = input.PermanentBan,
RequestedAtUtc = input.RequestedAtUtc,
Results = results
}));
}
private static async Task<TargetResolution> ResolveTargetsAsync(
BotManagementService botManagement,
int configuredBotId,
bool useAllChats,
HashSet<long> configuredChatIds,
CancellationToken cancellationToken)
{
var bots = (await botManagement.GetAllBotsAsync())
.Where(b => b.IsActive)
.OrderBy(b => b.Id)
.ToList();
if (configuredBotId > 0)
bots = bots.Where(b => b.Id == configuredBotId).ToList();
var groups = new List<TargetGroup>();
var totalChats = 0;
foreach (var bot in bots)
{
cancellationToken.ThrowIfCancellationRequested();
var chats = (await botManagement.GetChatsAsync(bot.Id)).ToList();
if (!useAllChats && configuredChatIds.Count > 0)
chats = chats.Where(c => configuredChatIds.Contains(c.TelegramId)).ToList();
if (chats.Count == 0)
continue;
groups.Add(new TargetGroup(bot, chats));
totalChats += chats.Count;
}
return new TargetResolution(groups, totalChats);
}
private static string SerializeIndented<T>(T value)
{
return JsonSerializer.Serialize(value, new JsonSerializerOptions { WriteIndented = true });
}
private sealed record TargetGroup(Bot Bot, List<BotChannel> Chats);
private sealed record TargetResolution(List<TargetGroup> Groups, int TotalChats);
}
+12 -152
View File
@@ -1,6 +1,5 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
@@ -8,7 +7,6 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using System.Text.Json;
using TelegramPanel.Core.Services;
using TelegramPanel.Core.Services.Telegram;
using TelegramPanel.Core.BatchTasks;
using TelegramPanel.Data.Entities;
@@ -27,7 +25,6 @@ public static class KickApi
HttpContext http,
IConfiguration configuration,
BotManagementService botManagement,
BotTelegramService botTelegram,
BatchTaskManagementService taskManagement,
ILoggerFactory loggerFactory,
CancellationToken cancellationToken)
@@ -86,10 +83,10 @@ public static class KickApi
false,
"未配置任何可操作的频道/群组(请先在页面选择机器人与频道/群组)",
new KickSummary(0, 0, 0),
Array.Empty<KickResultItem>()));
Array.Empty<KickResultItem>()));
}
// 记录到任务中心(即使外部调用返回了,也能追溯这次操作)
// 提交到任务中心,由后台执行(避免反代/CF 524 超时)
var createdTask = await taskManagement.CreateTaskAsync(new BatchTask
{
TaskType = BatchTaskTypes.ExternalApiKick,
@@ -108,120 +105,17 @@ public static class KickApi
})
});
await taskManagement.StartTaskAsync(createdTask.Id);
logger.LogInformation("Kick task queued: taskId={TaskId} api={ApiName} botId={BotId} total={Total} userId={UserId} permanent={Permanent}",
createdTask.Id, matched.Name ?? "", configuredBotId, targets.TotalChats, request.UserId, permanentBan);
var results = new List<KickResultItem>(targets.TotalChats);
var completed = 0;
var failedCount = 0;
try
{
foreach (var group in targets.Groups)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
var banResult = await botTelegram.BanChatMemberAsync(
botId: group.Bot.Id,
channelTelegramIds: group.Chats.Select(x => x.TelegramId).ToList(),
userId: request.UserId,
permanentBan: permanentBan,
cancellationToken: cancellationToken);
var failures = banResult.Failures;
foreach (var chat in group.Chats)
{
failures.TryGetValue(chat.TelegramId, out var err);
var ok = err == null;
results.Add(new KickResultItem(
ChatId: chat.TelegramId.ToString(),
Title: chat.Title,
Success: ok,
Error: err));
if (ok) completed++; else failedCount++;
}
}
catch (Exception ex)
{
logger.LogWarning(ex, "Kick API failed on bot {BotId} (chats={Count})", group.Bot.Id, group.Chats.Count);
foreach (var chat in group.Chats)
{
results.Add(new KickResultItem(
ChatId: chat.TelegramId.ToString(),
Title: chat.Title,
Success: false,
Error: ex.Message));
failedCount++;
}
}
await taskManagement.UpdateTaskProgressAsync(createdTask.Id, completed, failedCount);
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
await taskManagement.UpdateTaskProgressAsync(createdTask.Id, completed, failedCount == 0 ? 1 : failedCount);
await taskManagement.UpdateTaskConfigAsync(createdTask.Id, SerializeIndented(new KickTaskLog
{
ApiName = matched.Name ?? "",
BotId = configuredBotId,
UseAllChats = useAllChats,
ChatIds = configuredChatSet.OrderBy(x => x).ToList(),
UserId = request.UserId,
PermanentBan = permanentBan,
RequestedAtUtc = DateTime.UtcNow,
Results = results,
Canceled = true
}));
await taskManagement.CompleteTaskAsync(createdTask.Id, success: false);
throw;
}
catch (Exception ex)
{
logger.LogWarning(ex, "Kick API failed (taskId={TaskId})", createdTask.Id);
await taskManagement.UpdateTaskProgressAsync(createdTask.Id, completed, failedCount == 0 ? 1 : failedCount);
await taskManagement.UpdateTaskConfigAsync(createdTask.Id, SerializeIndented(new KickTaskLog
{
ApiName = matched.Name ?? "",
BotId = configuredBotId,
UseAllChats = useAllChats,
ChatIds = configuredChatSet.OrderBy(x => x).ToList(),
UserId = request.UserId,
PermanentBan = permanentBan,
RequestedAtUtc = DateTime.UtcNow,
Results = results,
Error = ex.Message
}));
await taskManagement.CompleteTaskAsync(createdTask.Id, success: false);
throw;
}
var okCount = results.Count(x => x.Success);
var total = results.Count;
var failedTotal = total - okCount;
var actionText = permanentBan ? "Banned" : "Kicked";
var message = $"{actionText} user {request.UserId} from {okCount}/{total} chats";
await taskManagement.UpdateTaskConfigAsync(createdTask.Id, SerializeIndented(new KickTaskLog
{
ApiName = matched.Name ?? "",
BotId = configuredBotId,
UseAllChats = useAllChats,
ChatIds = configuredChatSet.OrderBy(x => x).ToList(),
UserId = request.UserId,
PermanentBan = permanentBan,
RequestedAtUtc = DateTime.UtcNow,
Results = results
}));
await taskManagement.CompleteTaskAsync(createdTask.Id, success: failedTotal == 0);
return Results.Ok(new KickResponse(
Success: true,
Message: message,
Summary: new KickSummary(total, okCount, failedTotal),
Results: results));
return Results.Json(
new KickResponse(
Success: true,
Message: $"已提交后台任务 #{createdTask.Id},请到面板【任务中心】查看进度与结果。",
Summary: new KickSummary(targets.TotalChats, 0, 0),
Results: Array.Empty<KickResultItem>(),
TaskId: createdTask.Id),
statusCode: StatusCodes.Status202Accepted);
}
private static async Task<TargetResolution> ResolveTargetsAsync(
@@ -280,41 +174,7 @@ public static class KickApi
return JsonSerializer.Serialize(value, new JsonSerializerOptions { WriteIndented = true });
}
public sealed record KickRequest(
[property: JsonPropertyName("user_id")] long UserId,
[property: JsonPropertyName("permanent_ban")] bool? PermanentBan = null);
public sealed record KickResponse(
[property: JsonPropertyName("success")] bool Success,
[property: JsonPropertyName("message")] string Message,
[property: JsonPropertyName("summary")] KickSummary Summary,
[property: JsonPropertyName("results")] IReadOnlyList<KickResultItem> Results);
public sealed record KickSummary(
[property: JsonPropertyName("total")] int Total,
[property: JsonPropertyName("success")] int Success,
[property: JsonPropertyName("failed")] int Failed);
public sealed record KickResultItem(
[property: JsonPropertyName("chat_id")] string ChatId,
[property: JsonPropertyName("title")] string Title,
[property: JsonPropertyName("success")] bool Success,
[property: JsonPropertyName("error")] string? Error);
private sealed record TargetGroup(Bot Bot, List<BotChannel> Chats);
private sealed record TargetResolution(List<TargetGroup> Groups, int TotalChats);
private sealed class KickTaskLog
{
public string ApiName { get; set; } = "";
public int BotId { get; set; }
public bool UseAllChats { get; set; }
public List<long> ChatIds { get; set; } = new();
public long UserId { get; set; }
public bool PermanentBan { get; set; }
public DateTime RequestedAtUtc { get; set; }
public bool Canceled { get; set; }
public string? Error { get; set; }
public List<KickResultItem>? Results { get; set; }
}
}
@@ -0,0 +1,62 @@
using System.Text.Json.Serialization;
namespace TelegramPanel.Web.ExternalApi;
public sealed record KickRequest(
[property: JsonPropertyName("user_id")] long UserId,
[property: JsonPropertyName("permanent_ban")] bool? PermanentBan = null);
public sealed record KickResponse(
[property: JsonPropertyName("success")] bool Success,
[property: JsonPropertyName("message")] string Message,
[property: JsonPropertyName("summary")] KickSummary Summary,
[property: JsonPropertyName("results")] IReadOnlyList<KickResultItem> Results,
[property: JsonPropertyName("task_id")] int? TaskId = null);
public sealed record KickSummary(
[property: JsonPropertyName("total")] int Total,
[property: JsonPropertyName("success")] int Success,
[property: JsonPropertyName("failed")] int Failed);
public sealed record KickResultItem(
[property: JsonPropertyName("chat_id")] string ChatId,
[property: JsonPropertyName("title")] string Title,
[property: JsonPropertyName("success")] bool Success,
[property: JsonPropertyName("error")] string? Error);
public sealed class KickTaskLog
{
[JsonPropertyName("api_name")]
public string ApiName { get; set; } = "";
/// <summary>
/// 0=all bots
/// </summary>
[JsonPropertyName("bot_id")]
public int BotId { get; set; }
[JsonPropertyName("use_all_chats")]
public bool UseAllChats { get; set; }
[JsonPropertyName("chat_ids")]
public List<long> ChatIds { get; set; } = new();
[JsonPropertyName("user_id")]
public long UserId { get; set; }
[JsonPropertyName("permanent_ban")]
public bool PermanentBan { get; set; }
[JsonPropertyName("requested_at_utc")]
public DateTime RequestedAtUtc { get; set; }
[JsonPropertyName("canceled")]
public bool Canceled { get; set; }
[JsonPropertyName("error")]
public string? Error { get; set; }
[JsonPropertyName("results")]
public List<KickResultItem>? Results { get; set; }
}
@@ -2,12 +2,14 @@ using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using MudBlazor;
using TelegramPanel.Modules;
using TelegramPanel.Web.Components.ModulePages;
using TelegramPanel.Web.ExternalApi;
namespace TelegramPanel.Web.Modules.BuiltIn;
public sealed class KickApiModule : ITelegramPanelModule, IModuleApiProvider
public sealed class KickApiModule : ITelegramPanelModule, IModuleApiProvider, IModuleUiProvider
{
public KickApiModule(string version)
{
@@ -25,7 +27,7 @@ public sealed class KickApiModule : ITelegramPanelModule, IModuleApiProvider
public void ConfigureServices(IServiceCollection services, ModuleHostContext context)
{
// built-in:无需额外注入
services.AddScoped<IModuleTaskHandler, ExternalApiKickTaskHandler>();
}
public void MapEndpoints(IEndpointRouteBuilder endpoints, ModuleHostContext context)
@@ -49,4 +51,22 @@ public sealed class KickApiModule : ITelegramPanelModule, IModuleApiProvider
Order = 10
};
}
public IEnumerable<ModuleNavItem> GetNavItems(ModuleHostContext context)
{
yield break;
}
public IEnumerable<ModulePageDefinition> GetPages(ModuleHostContext context)
{
yield return new ModulePageDefinition
{
Key = "kick",
Title = "踢人/封禁",
Icon = Icons.Material.Filled.PersonRemove,
Group = "外部 API",
Order = 10,
ComponentType = typeof(KickApiPage).AssemblyQualifiedName ?? ""
};
}
}
+76 -23
View File
@@ -15,6 +15,7 @@ using TelegramPanel.Web.Services;
using Microsoft.Extensions.Logging;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.Data.Sqlite;
using System.Diagnostics;
// 诊断:对某个目录下的 *.json/*.session 做一次“可转换/可校验”检查(不写数据库)
// 用法:dotnet run --project src/TelegramPanel.Web -- --diag-session-dir "D:/path/to/dir"
@@ -254,24 +255,64 @@ static int ReadRetainedFileCountLimit(IConfiguration configuration)
var retainedFileCountLimit = ReadRetainedFileCountLimit(builder.Configuration);
var serilogEnabled = builder.Configuration.GetValue("Serilog:Enabled", false);
if (!serilogEnabled)
var loggerConfig = new LoggerConfiguration()
.Enrich.FromLogContext();
if (serilogEnabled)
{
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Fatal()
.CreateLogger();
loggerConfig = loggerConfig
.ReadFrom.Configuration(builder.Configuration)
// buffered=true:降低磁盘抖动/IO 阻塞导致的请求卡顿风险(尤其是低配 VPS/挂载卷/杀软扫描场景)
.WriteTo.File(
"logs/telegram-panel-.log",
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: retainedFileCountLimit,
buffered: true,
flushToDiskInterval: TimeSpan.FromSeconds(1));
}
else
{
Log.Logger = new LoggerConfiguration()
.ReadFrom.Configuration(builder.Configuration)
.Enrich.FromLogContext()
.WriteTo.Console()
.WriteTo.File("logs/telegram-panel-.log", rollingInterval: RollingInterval.Day, retainedFileCountLimit: retainedFileCountLimit)
.CreateLogger();
// 关闭“详细日志输出”时,仍保留 Error/Fatal:避免进程异常退出却完全看不到日志。
loggerConfig = loggerConfig.MinimumLevel.Error();
}
loggerConfig = loggerConfig.WriteTo.Console();
Log.Logger = loggerConfig.CreateLogger();
builder.Host.UseSerilog();
// 兜底:即使日志配置被关闭/过滤,也尽量把致命异常打到 stderr
AppDomain.CurrentDomain.UnhandledException += (_, e) =>
{
try
{
if (e.ExceptionObject is Exception ex)
Log.Fatal(ex, "Unhandled exception (IsTerminating={IsTerminating})", e.IsTerminating);
else
Log.Fatal("Unhandled exception: {ExceptionObject} (IsTerminating={IsTerminating})", e.ExceptionObject, e.IsTerminating);
}
catch
{
// ignore
}
};
TaskScheduler.UnobservedTaskException += (_, e) =>
{
try
{
Log.Error(e.Exception, "Unobserved task exception");
}
catch
{
// ignore
}
finally
{
e.SetObserved();
}
};
// Add services to the container.
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
@@ -670,7 +711,8 @@ app.UseStaticFiles();
app.UseAntiforgery();
// Serilog 请求日志
app.UseSerilogRequestLogging();
if (serilogEnabled)
app.UseSerilogRequestLogging();
app.UseAuthentication();
app.UseAuthorization();
@@ -969,11 +1011,6 @@ app.MapPost("/api/bot/webhook/{secretToken}", async (
}
}
// 从路径中的 secretToken 提取 bot token
// 我们使用 SHA256(bot_token) 作为 URL 中的 secret,避免暴露真实 token
// 但更简单的方式是直接用 bot token(Telegram 官方也是这么做的)
// 这里我们假设 secretToken 就是 bot token 的一部分(安全的做法是用 hash)
// 读取请求体
using var reader = new System.IO.StreamReader(http.Request.Body);
var body = await reader.ReadToEndAsync(cancellationToken);
@@ -989,10 +1026,16 @@ app.MapPost("/api/bot/webhook/{secretToken}", async (
using var doc = System.Text.Json.JsonDocument.Parse(body);
var update = doc.RootElement;
// 尝试从数据库查找匹配的 bot token
// 由于 URL 中不应该暴露完整 token,我们需要一个映射机制
// 这里简化处理:直接用 secretToken 作为查找键
var success = await updateHub.InjectWebhookUpdateAsync(secretToken, update.Clone(), cancellationToken);
var botToken = await updateHub.ResolveBotTokenFromWebhookPathAsync(secretToken, cancellationToken);
if (string.IsNullOrWhiteSpace(botToken))
{
logger.LogWarning("Webhook update rejected: unknown or inactive bot");
return Results.NotFound();
}
var sw = Stopwatch.StartNew();
var success = await updateHub.InjectWebhookUpdateAsync(botToken, update.Clone(), cancellationToken);
sw.Stop();
if (!success)
{
@@ -1000,8 +1043,11 @@ app.MapPost("/api/bot/webhook/{secretToken}", async (
return Results.NotFound();
}
logger.LogDebug("Webhook update processed: update_id={UpdateId}",
update.TryGetProperty("update_id", out var uid) ? uid.GetInt64() : 0);
var updateId = update.TryGetProperty("update_id", out var uid) ? uid.GetInt64() : 0;
if (sw.Elapsed > TimeSpan.FromSeconds(2))
logger.LogWarning("Webhook update processed slowly: update_id={UpdateId} elapsed_ms={ElapsedMs}", updateId, (long)sw.Elapsed.TotalMilliseconds);
else
logger.LogDebug("Webhook update processed: update_id={UpdateId} elapsed_ms={ElapsedMs}", updateId, (long)sw.Elapsed.TotalMilliseconds);
return Results.Ok();
}
@@ -1017,4 +1063,11 @@ app.MapPost("/api/bot/webhook/{secretToken}", async (
Log.Information("Telegram Panel started");
app.Run();
try
{
app.Run();
}
finally
{
Log.CloseAndFlush();
}
@@ -98,9 +98,10 @@ public sealed class WebhookRegistrationService : BackgroundService
{
var token = bot.Token!.Trim();
// 构建 Webhook URL:baseUrl + /api/bot/webhook/{token}
// 注意:这里用 bot token 作为 URL 中的 secretToken,Telegram 会在请求头中发送配置的 secretToken
var webhookUrl = $"{baseUrl.TrimEnd('/')}/api/bot/webhook/{Uri.EscapeDataString(token)}";
// 构建 Webhook URL:baseUrl + /api/bot/webhook/{SHA256(token)}
// 说明:避免在反向代理/access log 中泄露真实 bot token。
var pathToken = WebhookTokenHelper.ToWebhookPathToken(token);
var webhookUrl = $"{baseUrl.TrimEnd('/')}/api/bot/webhook/{pathToken}";
await botApi.SetWebhookAsync(
token: token,