merge(main): 合并 dev 分支已发布的 tdata 导入与相关改进
This commit is contained in:
commit
cd8a2bba50
19 files changed
+2135
-201
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>
|
||||
|
||||
@@ -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 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);
|
||||
}
|
||||
|
||||
@@ -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 ?? ""
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user