From 1a2cf134225dccbccd07de2699d809a06004e969 Mon Sep 17 00:00:00 2001 From: meoacgx Date: Mon, 29 Dec 2025 18:18:53 +0800 Subject: [PATCH] release: v1.2.3 --- Directory.Build.props | 8 +- README.md | 2 +- docs/advanced.md | 24 ++ .../Models/TelegramAccountWasteJudge.cs | 103 +++++++ .../Services/AccountManagementService.cs | 121 ++++++++ .../Services/BotManagementService.cs | 10 + .../Services/Telegram/BotUpdateHub.cs | 107 ++++++- .../Services/Telegram/ChannelService.cs | 109 ++++++- .../Repositories/AccountRepository.cs | 24 +- .../Dialogs/ChannelBatchAdminsDialog.razor | 280 ++++++++++++++---- .../Dialogs/ChannelBatchInviteDialog.razor | 120 ++++++++ .../Dialogs/VersionInfoDialog.razor | 11 +- .../Components/Layout/MainLayout.razor | 2 +- .../Components/Pages/Accounts.razor | 257 +++++++++++++++- .../Components/Pages/Bots.razor | 38 ++- .../Components/Pages/Settings.razor | 49 ++- src/TelegramPanel.Web/Program.cs | 3 + .../Services/ChannelAdminDefaultsService.cs | 91 ++++++ .../Services/ChannelAdminPresetsService.cs | 180 +++++++++++ .../Services/ChannelInvitePresetsService.cs | 180 +++++++++++ 20 files changed, 1634 insertions(+), 85 deletions(-) create mode 100644 src/TelegramPanel.Core/Models/TelegramAccountWasteJudge.cs create mode 100644 src/TelegramPanel.Web/Services/ChannelAdminDefaultsService.cs create mode 100644 src/TelegramPanel.Web/Services/ChannelAdminPresetsService.cs create mode 100644 src/TelegramPanel.Web/Services/ChannelInvitePresetsService.cs diff --git a/Directory.Build.props b/Directory.Build.props index d991d00..3964d40 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -5,10 +5,10 @@ - 1.2.2 - 1.2.2.0 - 1.2.2.0 - 1.2.2 + 1.2.3 + 1.2.3.0 + 1.2.3.0 + 1.2.3 Telegram Panel Contributors diff --git a/README.md b/README.md index 0f6b427..330a21b 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ - 🔐 **二级密码(2FA)与找回邮箱**:支持单个/批量修改二级密码;支持绑定/换绑 2FA 找回邮箱(验证码确认) - 🧯 **忘记二级密码可申请重置**:支持单个或批量向 Telegram 提交“忘记密码重置”申请(通常等待 7 天后可重新设置) - 🪪 **账号资料管理**:支持单账号编辑昵称/Bio/用户名/头像;支持批量修改昵称(自动追加手机号后 4 位便于区分)与批量修改 Bio -- 🤖 **Bot 频道管理**:用于管理“频道创建人不在系统中”的频道(把 Bot 设为管理员即可纳入管理),支持批量导出链接、批量设置管理员 +- 🤖 **Bot 频道管理**:用于管理“频道创建人不在系统中”的频道(把 Bot 设为管理员即可纳入管理),支持批量导出链接、批量设置管理员;支持对每个 Bot 一键启用/停用(停用后相关后台不再轮询 getUpdates) ## 🧊 防冻结指南(新号必看) diff --git a/docs/advanced.md b/docs/advanced.md index 3b8bcfc..60fa9dd 100644 --- a/docs/advanced.md +++ b/docs/advanced.md @@ -29,6 +29,17 @@ 检测结果会持久化到数据库,避免刷新页面又变回“未检测”。 +## 清理废号(封禁/受限/未登录/session 失效) + +在「账号列表」与「外置验证码链接」页面支持“清理废号”(多选批量): + +- 会先执行 Telegram 状态检测(可选普通/深度) +- 仅当判定为废号(封禁/受限/被冻结/需要 2FA/Session 失效或损坏)才会删除 +- 删除范围:数据库记录 + `*.session`(含常见备份/同名 json) +- 若遇到 `*.session` 文件被占用,会先尝试从 `TelegramClientPool` 释放客户端并重试删除 + +另外,系统「账号列表」支持“一键清理所有废号”(扫描系统全部账号)。 + ## 配置项速查 Docker 下常用环境变量(见 `docker-compose.yml`): @@ -38,3 +49,16 @@ Docker 下常用环境变量(见 `docker-compose.yml`): - `AdminAuth__CredentialsPath`:后台密码文件(默认 `/data/admin_auth.json`) - `Sync__AutoSyncEnabled`:账号创建的频道/群组自动同步(默认关闭) - `Telegram__BotAutoSyncEnabled`:Bot 频道轮询自动同步(默认关闭) + +## UI 保存到本地覆盖配置 + +面板里的部分“保存”按钮会把设置写入 `appsettings.local.json`(Docker 下为 `/data/appsettings.local.json`),常见键: + +- `Telegram:BotAutoSyncEnabled` / `Telegram:BotAutoSyncIntervalSeconds`:Bot 频道后台自动同步轮询开关/间隔 +- `ChannelAdminDefaults:Rights`:批量设置管理员的“默认权限” +- `ChannelAdminPresets:Presets`:批量设置管理员的“用户名列表预设”(名称 -> usernames) +- `ChannelInvitePresets:Presets`:批量邀请成员的“用户名列表预设”(名称 -> usernames) + +## Bot 启用/停用(每个 Bot) + +机器人管理页可以对单个 Bot 启用/停用:停用后该 Bot 不会再被后台轮询 `getUpdates`,也不会被需要 Bot 的模块/任务使用。 diff --git a/src/TelegramPanel.Core/Models/TelegramAccountWasteJudge.cs b/src/TelegramPanel.Core/Models/TelegramAccountWasteJudge.cs new file mode 100644 index 0000000..34220b4 --- /dev/null +++ b/src/TelegramPanel.Core/Models/TelegramAccountWasteJudge.cs @@ -0,0 +1,103 @@ +namespace TelegramPanel.Core.Models; + +/// +/// “废号”判定:用于在批量清理时识别可直接删除的账号。 +/// +public static class TelegramAccountWasteJudge +{ + public static bool IsWaste(TelegramAccountStatusResult? status) => TryGetWasteReason(status, out _); + + public static bool TryGetWasteReason(TelegramAccountStatusResult? status, out string reason) + { + reason = string.Empty; + + if (status == null) + return false; + + // Profile 兜底(理论上 Summary 已覆盖,但保持稳健) + if (status.Profile is { IsDeleted: true }) + { + reason = "账号已注销/被删除"; + return true; + } + + if (status.Profile is { IsRestricted: true }) + { + reason = "账号受限(Restricted)"; + return true; + } + + var summary = (status.Summary ?? string.Empty).Trim(); + if (summary.Length == 0) + return false; + + // 1) 明确的封禁/失效 + if (summary.Contains("账号被封禁", StringComparison.OrdinalIgnoreCase) + || summary.Contains("被封禁/停用", StringComparison.OrdinalIgnoreCase) + || summary.Contains("USER_DEACTIVATED", StringComparison.OrdinalIgnoreCase) + || summary.Contains("PHONE_NUMBER_BANNED", StringComparison.OrdinalIgnoreCase)) + { + reason = "账号被封禁/停用"; + return true; + } + + // 连接失败/探测失败:在“出售前清理废号”的场景下,用户预期将其视为不可用账号直接清理。 + if (summary.Contains("连接失败", StringComparison.OrdinalIgnoreCase)) + { + reason = "连接失败(无法连通 Telegram)"; + return true; + } + + if (summary.Contains("创建频道探测失败", StringComparison.OrdinalIgnoreCase)) + { + reason = "创建频道探测失败(创建频道能力异常)"; + return true; + } + + if (summary.Contains("Session 失效", StringComparison.OrdinalIgnoreCase) + || summary.Contains("AUTH_KEY_UNREGISTERED", StringComparison.OrdinalIgnoreCase)) + { + reason = "Session 失效"; + return true; + } + + if (summary.Contains("Session 无法读取", StringComparison.OrdinalIgnoreCase) + || summary.Contains("Can't read session block", StringComparison.OrdinalIgnoreCase)) + { + reason = "Session 损坏/不匹配"; + return true; + } + + // 2) 受限/冻结/未登录 + if (summary.Contains("账号受限", StringComparison.OrdinalIgnoreCase) + || summary.Contains("Restricted", StringComparison.OrdinalIgnoreCase)) + { + reason = "账号受限(Restricted)"; + return true; + } + + if (summary.Contains("账号被冻结", StringComparison.OrdinalIgnoreCase) + || summary.Contains("FROZEN_METHOD_INVALID", StringComparison.OrdinalIgnoreCase)) + { + reason = "账号被冻结"; + return true; + } + + if (summary.Contains("需要两步验证密码", StringComparison.OrdinalIgnoreCase) + || summary.Contains("SESSION_PASSWORD_NEEDED", StringComparison.OrdinalIgnoreCase)) + { + reason = "需要两步验证密码(未登录)"; + return true; + } + + if (summary.Contains("账号已注销", StringComparison.OrdinalIgnoreCase) + || summary.Contains("被删除", StringComparison.OrdinalIgnoreCase) + || summary.Contains("已注销/被删除", StringComparison.OrdinalIgnoreCase)) + { + reason = "账号已注销/被删除"; + return true; + } + + return false; + } +} diff --git a/src/TelegramPanel.Core/Services/AccountManagementService.cs b/src/TelegramPanel.Core/Services/AccountManagementService.cs index b06a17e..cd9758e 100644 --- a/src/TelegramPanel.Core/Services/AccountManagementService.cs +++ b/src/TelegramPanel.Core/Services/AccountManagementService.cs @@ -130,6 +130,63 @@ public class AccountManagementService } } + /// + /// 清理废号:强制尝试删除账号记录与 session 文件。 + /// - 删除前会先从 TelegramClientPool 释放客户端,避免 session 被占用 + /// - 若仍存在被占用的 session 文件,会重试并在最终失败时抛出异常(用于提示 UI) + /// + public async Task PurgeAccountAsync(int id, CancellationToken cancellationToken = default) + { + var account = await _accountRepository.GetByIdAsync(id); + if (account == null) + return; + + try + { + await _clientPool.RemoveClientAsync(account.Id); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to remove client for account {AccountId} before purge", account.Id); + } + + var sessionCandidates = ResolveSessionFileCandidates(account).ToList(); + var existingSessionFiles = sessionCandidates + .Select(p => SafeGetFullPath(p)) + .Where(p => !string.IsNullOrWhiteSpace(p) && File.Exists(p)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + var failedSessionDeletes = new List(); + foreach (var sessionPath in existingSessionFiles) + { + cancellationToken.ThrowIfCancellationRequested(); + + var ok = await TryDeleteFileWithRetriesAsync( + fullPath: sessionPath, + maxAttempts: 6, + baseDelayMs: 150, + accountId: account.Id, + cancellationToken: cancellationToken); + + if (!ok) + failedSessionDeletes.Add(sessionPath); + } + + if (failedSessionDeletes.Count > 0) + { + var hint = string.Join(Environment.NewLine, failedSessionDeletes.Take(5)); + if (failedSessionDeletes.Count > 5) + hint += Environment.NewLine + $"...(仅展示前 5 个,共 {failedSessionDeletes.Count} 个)"; + + throw new InvalidOperationException("无法删除 session 文件(可能仍被占用,请稍后重试):\n" + hint); + } + + // session 删除成功后,再尽力清理其它关联文件(json/备份等) + TryDeleteAccountFiles(account); + await _accountRepository.DeleteAsync(account); + } + private void TryDeleteAccountFiles(Account account) { try @@ -155,6 +212,18 @@ public class AccountManagementService } } + private static string SafeGetFullPath(string path) + { + try + { + return Path.GetFullPath(path); + } + catch + { + return path; + } + } + private IEnumerable ResolveSessionFileCandidates(Account account) { var set = new HashSet(StringComparer.OrdinalIgnoreCase); @@ -238,6 +307,58 @@ public class AccountManagementService } } + private async Task TryDeleteFileWithRetriesAsync( + string fullPath, + int maxAttempts, + int baseDelayMs, + int accountId, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(fullPath)) + return true; + + if (maxAttempts < 1) maxAttempts = 1; + if (baseDelayMs < 0) baseDelayMs = 0; + + for (var attempt = 1; attempt <= maxAttempts; attempt++) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + if (!File.Exists(fullPath)) + return true; + + File.Delete(fullPath); + _logger.LogInformation("Deleted file: {Path}", fullPath); + return true; + } + catch (Exception ex) when (attempt < maxAttempts && (ex is IOException || ex is UnauthorizedAccessException)) + { + // 典型场景:session 被 WTelegram/其它后台任务占用,先再移除一次 client,再做短退避 + try + { + await _clientPool.RemoveClientAsync(accountId); + } + catch + { + // ignore + } + + var delayMs = baseDelayMs * attempt; + if (delayMs > 3000) delayMs = 3000; + await Task.Delay(TimeSpan.FromMilliseconds(delayMs), cancellationToken); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to delete file: {Path}", fullPath); + return false; + } + } + + return !File.Exists(fullPath); + } + private static string NormalizePhone(string? phone) { if (string.IsNullOrWhiteSpace(phone)) diff --git a/src/TelegramPanel.Core/Services/BotManagementService.cs b/src/TelegramPanel.Core/Services/BotManagementService.cs index acd9f87..0b30311 100644 --- a/src/TelegramPanel.Core/Services/BotManagementService.cs +++ b/src/TelegramPanel.Core/Services/BotManagementService.cs @@ -70,6 +70,16 @@ public class BotManagementService await _botRepository.UpdateAsync(bot); } + public async Task SetBotActiveStatusAsync(int botId, bool isActive) + { + var bot = await _botRepository.GetByIdAsync(botId); + if (bot == null) + return; + + bot.IsActive = isActive; + await _botRepository.UpdateAsync(bot); + } + public async Task DeleteBotAsync(int id) { var bot = await _botRepository.GetByIdAsync(id); diff --git a/src/TelegramPanel.Core/Services/Telegram/BotUpdateHub.cs b/src/TelegramPanel.Core/Services/Telegram/BotUpdateHub.cs index 0121dbc..0f463fe 100644 --- a/src/TelegramPanel.Core/Services/Telegram/BotUpdateHub.cs +++ b/src/TelegramPanel.Core/Services/Telegram/BotUpdateHub.cs @@ -118,6 +118,8 @@ public sealed class BotUpdateHub : IAsyncDisposable FullMode = BoundedChannelFullMode.DropOldest }; + private const string AllowedMyChatMemberOnlyJson = "[\"my_chat_member\"]"; + private readonly int _persistBotId; private readonly string _token; private readonly IServiceScopeFactory _scopeFactory; @@ -142,6 +144,7 @@ public sealed class BotUpdateHub : IAsyncDisposable int persistBotId, string token, long nextOffset, + IReadOnlyList? initialPendingMyChatMember, IServiceScopeFactory scopeFactory, TelegramBotApiClient botApi, ILogger logger) @@ -153,6 +156,19 @@ public sealed class BotUpdateHub : IAsyncDisposable _botApi = botApi; _logger = logger; + if (initialPendingMyChatMember is { Count: > 0 }) + { + lock (_pendingLock) + { + foreach (var u in initialPendingMyChatMember) + { + _pendingMyChatMember.Enqueue(u); + while (_pendingMyChatMember.Count > PendingMyChatMemberMax) + _pendingMyChatMember.Dequeue(); + } + } + } + _loopTask = Task.Run(() => RunAsync(_cts.Token)); } @@ -169,16 +185,20 @@ public sealed class BotUpdateHub : IAsyncDisposable // - 如果数据库里已有 LastUpdateId,则从其后开始 // - 否则快进到最新,避免冷启动回放历史消息造成刷屏 long nextOffset; + List? pendingMyChatMember = null; if (lastUpdateId.HasValue) { nextOffset = lastUpdateId.Value + 1; } else { - nextOffset = await FastForwardOffsetAsync(botId, token, scopeFactory, botApi, logger, cancellationToken); + // 重要:冷启动快进会丢历史消息(避免刷屏),但不能丢 my_chat_member(否则 Bot 加入私密频道永远同步不到)。 + var r = await FastForwardOffsetAndCollectMyChatMemberAsync(botId, token, scopeFactory, botApi, logger, cancellationToken); + nextOffset = r.NextOffset; + pendingMyChatMember = r.PendingMyChatMember; } - return new BotPoller(botId, token, nextOffset, scopeFactory, botApi, logger); + return new BotPoller(botId, token, nextOffset, pendingMyChatMember, scopeFactory, botApi, logger); } public BotUpdateSubscription Subscribe() @@ -241,6 +261,15 @@ public sealed class BotUpdateHub : IAsyncDisposable { try { + // Bot 被停用(或 Token 被替换)时暂停轮询: + // 否则即使 UI 停用了 Bot,后台仍可能因为历史订阅而持续 getUpdates,导致 409/限流。 + if (!await IsBotPollingEnabledAsync(cancellationToken)) + { + conflictStreak = 0; + await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken); + continue; + } + // 没有订阅者时也继续拉取并确认 offset,避免积压导致后续刷屏; // 同时还能保证 Bot 频道自动同步等后台能力可工作。 @@ -298,6 +327,33 @@ public sealed class BotUpdateHub : IAsyncDisposable } } + private async Task IsBotPollingEnabledAsync(CancellationToken cancellationToken) + { + try + { + using var scope = _scopeFactory.CreateScope(); + var botRepo = scope.ServiceProvider.GetRequiredService(); + var bot = await botRepo.GetByIdAsync(_persistBotId); + if (bot == null || !bot.IsActive) + return false; + + var token = (bot.Token ?? string.Empty).Trim(); + if (!string.Equals(token, _token, StringComparison.Ordinal)) + { + // Bot Token 被替换了:停止旧 token 的轮询(新 token 会创建新 poller) + _logger.LogWarning("Bot token changed, pausing old poller: botId={BotId}", _persistBotId); + return false; + } + + return true; + } + catch + { + // 配置/数据库短暂异常时不应导致 poller 永久停掉 + return true; + } + } + private void Broadcast(JsonElement update) { // 即使当前没有订阅者,也要缓存 my_chat_member 更新: @@ -359,7 +415,9 @@ public sealed class BotUpdateHub : IAsyncDisposable } } - private static async Task FastForwardOffsetAsync( + private sealed record FastForwardResult(long NextOffset, List? PendingMyChatMember); + + private static async Task FastForwardOffsetAndCollectMyChatMemberAsync( int botId, string token, IServiceScopeFactory scopeFactory, @@ -367,6 +425,47 @@ public sealed class BotUpdateHub : IAsyncDisposable ILogger logger, CancellationToken cancellationToken) { + var pending = new List(); + + // A) 先尽力把 my_chat_member 全部捞出来(用于“Bot 新加入频道”的识别),避免被快进丢弃。 + long myOffset = 0; + for (var i = 0; i < 20; i++) + { + cancellationToken.ThrowIfCancellationRequested(); + + var updates = await botApi.CallAsync(token, "getUpdates", new Dictionary + { + ["offset"] = myOffset.ToString(), + ["timeout"] = "0", + ["limit"] = "100", + ["allowed_updates"] = AllowedMyChatMemberOnlyJson + }, cancellationToken); + + if (updates.ValueKind != JsonValueKind.Array) + break; + + long? maxUpdateId = null; + foreach (var u in updates.EnumerateArray()) + { + if (!TryGetUpdateId(u, out var id)) + continue; + + maxUpdateId = maxUpdateId.HasValue ? Math.Max(maxUpdateId.Value, id) : id; + if (u.ValueKind == JsonValueKind.Object && u.TryGetProperty("my_chat_member", out _)) + { + pending.Add(u.Clone()); + if (pending.Count > PendingMyChatMemberMax) + pending.RemoveAt(0); + } + } + + if (!maxUpdateId.HasValue) + break; + + myOffset = maxUpdateId.Value + 1; + } + + // B) 再快进到最新(覆盖所有 update 类型),避免冷启动回放历史消息刷屏。 long offset = 0; // 尝试最多 20 次(2000 条)以“清空积压”,避免首次启用模块直接刷历史。 @@ -420,7 +519,7 @@ public sealed class BotUpdateHub : IAsyncDisposable logger.LogWarning(ex, "FastForwardOffset persistence failed: botId={BotId}", botId); } - return offset; + return new FastForwardResult(offset, pending.Count > 0 ? pending : null); } public async ValueTask DisposeAsync() diff --git a/src/TelegramPanel.Core/Services/Telegram/ChannelService.cs b/src/TelegramPanel.Core/Services/Telegram/ChannelService.cs index 464a6ce..3ec1e29 100644 --- a/src/TelegramPanel.Core/Services/Telegram/ChannelService.cs +++ b/src/TelegramPanel.Core/Services/Telegram/ChannelService.cs @@ -390,6 +390,31 @@ public class ChannelService : IChannelService await client.Channels_EditAdmin(channel, resolved.User, chatAdminRights, title); + // 权限校验:Telegram 可能会静默“削减”你请求的权限(典型:执行账号本身没有 add_admins/promote 权限) + try + { + var granted = await TryGetGrantedAdminRightsAsync(client, channel, resolved.User.id); + if (granted.HasValue) + { + var missing = rights & ~granted.Value; + if (missing != Interfaces.AdminRights.None) + { + throw new InvalidOperationException( + $"已设置管理员,但部分权限未生效:{FormatAdminRights(missing)}。" + + "请确认:执行账号是该频道创建者,或执行账号在频道内拥有“添加管理员”权限。"); + } + } + } + catch (InvalidOperationException) + { + throw; + } + catch (Exception ex) + { + // 不阻塞主流程:有些频道无法拉取管理员列表(例如权限不足/风控),只记 debug 方便排查 + _logger.LogDebug(ex, "Verify admin rights failed for channel {ChannelId}", channelId); + } + _logger.LogInformation("Set @{Username} as admin in channel {ChannelId}", username, channelId); return true; } @@ -662,7 +687,8 @@ public class ChannelService : IChannelService private static ChatAdminRights ConvertAdminRights(Interfaces.AdminRights rights) { - var flags = ChatAdminRights.Flags.other; + // 注意:不要默认带上 unknown/other flag。某些情况下会导致服务端“净化/削减”你请求的权限。 + ChatAdminRights.Flags flags = 0; if (rights.HasFlag(Interfaces.AdminRights.ChangeInfo)) flags |= ChatAdminRights.Flags.change_info; @@ -690,5 +716,86 @@ public class ChannelService : IChannelService return new ChatAdminRights { flags = flags }; } + private static Interfaces.AdminRights ConvertAdminRights(ChatAdminRights rights) + { + var r = Interfaces.AdminRights.None; + var flags = rights.flags; + + if (flags.HasFlag(ChatAdminRights.Flags.change_info)) + r |= Interfaces.AdminRights.ChangeInfo; + if (flags.HasFlag(ChatAdminRights.Flags.post_messages)) + r |= Interfaces.AdminRights.PostMessages; + if (flags.HasFlag(ChatAdminRights.Flags.edit_messages)) + r |= Interfaces.AdminRights.EditMessages; + if (flags.HasFlag(ChatAdminRights.Flags.delete_messages)) + r |= Interfaces.AdminRights.DeleteMessages; + if (flags.HasFlag(ChatAdminRights.Flags.ban_users)) + r |= Interfaces.AdminRights.BanUsers; + if (flags.HasFlag(ChatAdminRights.Flags.invite_users)) + r |= Interfaces.AdminRights.InviteUsers; + if (flags.HasFlag(ChatAdminRights.Flags.pin_messages)) + r |= Interfaces.AdminRights.PinMessages; + if (flags.HasFlag(ChatAdminRights.Flags.manage_call)) + r |= Interfaces.AdminRights.ManageCall; + if (flags.HasFlag(ChatAdminRights.Flags.add_admins)) + r |= Interfaces.AdminRights.AddAdmins; + if (flags.HasFlag(ChatAdminRights.Flags.anonymous)) + r |= Interfaces.AdminRights.Anonymous; + if (flags.HasFlag(ChatAdminRights.Flags.manage_topics)) + r |= Interfaces.AdminRights.ManageTopics; + + return r; + } + + private async Task TryGetGrantedAdminRightsAsync(Client client, Channel channel, long userId) + { + // 取管理员列表(通常很小)。若频道管理员非常多,仍可能只返回一部分;这种场景下校验只作为“尽力而为”。 + var participants = await client.Channels_GetParticipants(channel, new ChannelParticipantsAdmins()); + foreach (var p in participants.participants) + { + if (p is ChannelParticipantCreator creator && creator.user_id == userId) + { + // 创建者权限视为 FullAdmin(至少包含本系统可表达的权限) + return Interfaces.AdminRights.FullAdmin | Interfaces.AdminRights.Anonymous | Interfaces.AdminRights.ManageTopics; + } + + if (p is ChannelParticipantAdmin admin && admin.user_id == userId) + { + if (admin.admin_rights == null) + return Interfaces.AdminRights.None; + return ConvertAdminRights(admin.admin_rights); + } + } + + return null; + } + + private static string FormatAdminRights(Interfaces.AdminRights rights) + { + if (rights == Interfaces.AdminRights.None) + return "无"; + + var parts = new List(); + void Add(Interfaces.AdminRights r, string label) + { + if (rights.HasFlag(r)) + parts.Add(label); + } + + Add(Interfaces.AdminRights.ChangeInfo, "修改信息"); + Add(Interfaces.AdminRights.PostMessages, "发消息"); + Add(Interfaces.AdminRights.EditMessages, "编辑消息"); + Add(Interfaces.AdminRights.DeleteMessages, "删除消息"); + Add(Interfaces.AdminRights.BanUsers, "封禁用户"); + Add(Interfaces.AdminRights.InviteUsers, "邀请用户"); + Add(Interfaces.AdminRights.PinMessages, "置顶消息"); + Add(Interfaces.AdminRights.ManageCall, "管理语音/直播"); + Add(Interfaces.AdminRights.AddAdmins, "添加管理员"); + Add(Interfaces.AdminRights.Anonymous, "匿名管理员"); + Add(Interfaces.AdminRights.ManageTopics, "管理话题"); + + return parts.Count == 0 ? rights.ToString() : string.Join("、", parts); + } + #endregion } diff --git a/src/TelegramPanel.Data/Repositories/AccountRepository.cs b/src/TelegramPanel.Data/Repositories/AccountRepository.cs index 58d69ba..b157279 100644 --- a/src/TelegramPanel.Data/Repositories/AccountRepository.cs +++ b/src/TelegramPanel.Data/Repositories/AccountRepository.cs @@ -27,18 +27,22 @@ public class AccountRepository : Repository, IAccountRepository { // 注意:SQLite LIKE 在默认 collation 下对 ASCII 通常不区分大小写;这里用 Like 保持可翻译性与可读性 var like = $"%{search}%"; + + // 允许用户直接粘贴 “+86 138 0013 8000” 等格式,统一提取纯数字后匹配 Phone 字段 + var phoneDigits = NormalizeDigits(search); + var phoneLike = phoneDigits.Length > 0 ? $"%{phoneDigits}%" : like; if (long.TryParse(search, out var uid) && uid > 0) { query = query.Where(a => a.UserId == uid - || EF.Functions.Like(a.Phone, like) + || EF.Functions.Like(a.Phone, phoneLike) || (a.Nickname != null && EF.Functions.Like(a.Nickname, like)) || (a.Username != null && EF.Functions.Like(a.Username, like))); } else { query = query.Where(a => - EF.Functions.Like(a.Phone, like) + EF.Functions.Like(a.Phone, phoneLike) || (a.Nickname != null && EF.Functions.Like(a.Nickname, like)) || (a.Username != null && EF.Functions.Like(a.Username, like))); } @@ -47,6 +51,22 @@ public class AccountRepository : Repository, IAccountRepository return query.OrderByDescending(a => a.Id); } + private static string NormalizeDigits(string text) + { + if (string.IsNullOrWhiteSpace(text)) + return string.Empty; + + Span buf = stackalloc char[text.Length]; + var n = 0; + foreach (var ch in text) + { + if (ch is >= '0' and <= '9') + buf[n++] = ch; + } + + return n == 0 ? string.Empty : new string(buf[..n]); + } + public override async Task GetByIdAsync(int id) { return await _dbSet diff --git a/src/TelegramPanel.Web/Components/Dialogs/ChannelBatchAdminsDialog.razor b/src/TelegramPanel.Web/Components/Dialogs/ChannelBatchAdminsDialog.razor index 63563f1..70d6bbe 100644 --- a/src/TelegramPanel.Web/Components/Dialogs/ChannelBatchAdminsDialog.razor +++ b/src/TelegramPanel.Web/Components/Dialogs/ChannelBatchAdminsDialog.razor @@ -2,6 +2,8 @@ @inject IChannelService ChannelService @inject ISnackbar Snackbar @inject IDialogService DialogService +@inject TelegramPanel.Web.Services.ChannelAdminDefaultsService DefaultsService +@inject TelegramPanel.Web.Services.ChannelAdminPresetsService PresetsService @using TelegramPanel.Core.Interfaces @using TelegramPanel.Data.Entities @@ -17,6 +19,25 @@ 目标频道:@channels.Count 个 + + + + (不使用预设) + @foreach (var p in Presets) + { + @p.Name + } + + + + + 删除预设 + + + + @foreach (var a in accounts) { @@ -27,31 +48,49 @@ + + + + + + + 保存为预设 + + + + - - 常用权限(推荐) - 全权限 - 自定义 - + 权限(可多选) + + 说明:这里选择的是“你希望目标账号拥有的权限”。如果设置后发现某些权限未生效,面板会提示缺失的权限名称。 + - @if (rightsMode == "custom") - { - - - @foreach (var item in CustomRightOptions) - { - - - - } - - - } + + 常用权限 + 全选权限 + 清空 + 保存为默认权限 + + + + + + + + + + + + + + + + + @@ -92,11 +131,28 @@ private int selectedAccountId; private string usernamesText = ""; private string adminTitle = "Admin"; - private string rightsMode = "basic"; // basic|full|custom - private AdminRights customRights = AdminRights.BasicAdmin; private int delayMs = 1500; private const string UsernamesHelperText = "每行一个 username 或 @username,将按“频道 × 用户”的顺序依次执行。"; + private bool loadingPresets; + private List Presets { get; set; } = new(); + private string selectedPresetName = ""; + private string presetNameToSave = ""; + + private RightsModel rights = new(); + private static readonly AdminRights FullRights = + AdminRights.ChangeInfo + | AdminRights.PostMessages + | AdminRights.EditMessages + | AdminRights.DeleteMessages + | AdminRights.BanUsers + | AdminRights.InviteUsers + | AdminRights.PinMessages + | AdminRights.ManageCall + | AdminRights.AddAdmins + | AdminRights.Anonymous + | AdminRights.ManageTopics; + private bool running; private int total; private int done; @@ -106,27 +162,7 @@ private int Progress => total <= 0 ? 0 : (int)Math.Round(done * 100d / total); - private AdminRights SelectedRights => rightsMode switch - { - "full" => AdminRights.FullAdmin, - "custom" => customRights, - _ => AdminRights.BasicAdmin - }; - - private static readonly IReadOnlyList<(AdminRights Right, string Label)> CustomRightOptions = new List<(AdminRights, string)> - { - (AdminRights.ChangeInfo, "修改信息"), - (AdminRights.PostMessages, "发消息"), - (AdminRights.EditMessages, "编辑消息"), - (AdminRights.DeleteMessages, "删除消息"), - (AdminRights.BanUsers, "封禁用户"), - (AdminRights.InviteUsers, "邀请用户"), - (AdminRights.PinMessages, "置顶消息"), - (AdminRights.ManageCall, "管理语音/直播"), - (AdminRights.AddAdmins, "添加管理员"), - (AdminRights.Anonymous, "匿名管理员"), - (AdminRights.ManageTopics, "管理话题") - }; + private AdminRights SelectedRights => rights.ToRights(); protected override async Task OnInitializedAsync() { @@ -143,19 +179,111 @@ selectedAccountId = DefaultAccountId; else selectedAccountId = accounts.FirstOrDefault()?.Id ?? 0; + + // 默认权限:优先读取上次保存的默认值;否则使用“常用权限” + var defaults = await DefaultsService.GetAsync(); + ApplyRights(defaults?.Rights ?? AdminRights.BasicAdmin); + + await ReloadPresetsAsync(); } - private bool HasRight(AdminRights right) + private async Task ReloadPresetsAsync() { - return (customRights & right) == right; + loadingPresets = true; + try + { + Presets = (await PresetsService.GetPresetsAsync()).ToList(); + } + finally + { + loadingPresets = false; + } } - private void SetRight(AdminRights right, bool enabled) + private async Task OnPresetChanged(string? name) { - if (enabled) - customRights |= right; - else - customRights &= ~right; + selectedPresetName = name ?? ""; + if (string.IsNullOrWhiteSpace(selectedPresetName)) + return; + + var p = Presets.FirstOrDefault(x => string.Equals(x.Name, selectedPresetName, StringComparison.OrdinalIgnoreCase)); + if (p == null) + return; + + usernamesText = string.Join(Environment.NewLine, p.Usernames.Select(x => $"@{x}")); + await Task.CompletedTask; + } + + private async Task SavePreset() + { + var name = (presetNameToSave ?? "").Trim(); + if (string.IsNullOrWhiteSpace(name)) + return; + + var usernames = ParseUsernames(usernamesText); + if (usernames.Count == 0) + { + Snackbar.Add("当前用户名列表为空,无法保存为预设", Severity.Warning); + return; + } + + try + { + await PresetsService.SavePresetAsync(name, usernames); + Snackbar.Add("已保存预设", Severity.Success); + presetNameToSave = ""; + await ReloadPresetsAsync(); + } + catch (Exception ex) + { + Snackbar.Add($"保存预设失败:{ex.Message}", Severity.Error); + } + } + + private async Task DeleteSelectedPreset() + { + if (string.IsNullOrWhiteSpace(selectedPresetName)) + return; + + bool? ok = await DialogService.ShowMessageBox( + title: "删除预设", + message: $"确定删除预设“{selectedPresetName}”吗?", + yesText: "删除", + cancelText: "取消"); + + if (ok != true) + return; + + try + { + await PresetsService.DeletePresetAsync(selectedPresetName); + Snackbar.Add("已删除预设", Severity.Success); + selectedPresetName = ""; + await ReloadPresetsAsync(); + } + catch (Exception ex) + { + Snackbar.Add($"删除预设失败:{ex.Message}", Severity.Error); + } + } + + private void ApplyRights(AdminRights selected) + { + rights = RightsModel.FromRights(selected); + StateHasChanged(); + } + + private async Task SaveAsDefaultRightsAsync() + { + try + { + await DefaultsService.SaveAsync(new TelegramPanel.Web.Services.ChannelAdminDefaults(SelectedRights)); + Snackbar.Add("已保存为默认权限", Severity.Success); + } + catch (Exception ex) + { + Snackbar.Add($"保存默认权限失败:{ex.Message}", Severity.Error); + } } private async Task Submit() @@ -283,6 +411,56 @@ MudDialog.Close(); } + private sealed class RightsModel + { + public bool ChangeInfo { get; set; } + public bool PostMessages { get; set; } + public bool EditMessages { get; set; } + public bool DeleteMessages { get; set; } + public bool BanUsers { get; set; } + public bool InviteUsers { get; set; } + public bool PinMessages { get; set; } + public bool ManageCall { get; set; } + public bool AddAdmins { get; set; } + public bool Anonymous { get; set; } + public bool ManageTopics { get; set; } + + public AdminRights ToRights() + { + var r = AdminRights.None; + if (ChangeInfo) r |= AdminRights.ChangeInfo; + if (PostMessages) r |= AdminRights.PostMessages; + if (EditMessages) r |= AdminRights.EditMessages; + if (DeleteMessages) r |= AdminRights.DeleteMessages; + if (BanUsers) r |= AdminRights.BanUsers; + if (InviteUsers) r |= AdminRights.InviteUsers; + if (PinMessages) r |= AdminRights.PinMessages; + if (ManageCall) r |= AdminRights.ManageCall; + if (AddAdmins) r |= AdminRights.AddAdmins; + if (Anonymous) r |= AdminRights.Anonymous; + if (ManageTopics) r |= AdminRights.ManageTopics; + return r; + } + + public static RightsModel FromRights(AdminRights rights) + { + return new RightsModel + { + ChangeInfo = rights.HasFlag(AdminRights.ChangeInfo), + PostMessages = rights.HasFlag(AdminRights.PostMessages), + EditMessages = rights.HasFlag(AdminRights.EditMessages), + DeleteMessages = rights.HasFlag(AdminRights.DeleteMessages), + BanUsers = rights.HasFlag(AdminRights.BanUsers), + InviteUsers = rights.HasFlag(AdminRights.InviteUsers), + PinMessages = rights.HasFlag(AdminRights.PinMessages), + ManageCall = rights.HasFlag(AdminRights.ManageCall), + AddAdmins = rights.HasFlag(AdminRights.AddAdmins), + Anonymous = rights.HasFlag(AdminRights.Anonymous), + ManageTopics = rights.HasFlag(AdminRights.ManageTopics) + }; + } + } + private static List ParseUsernames(string? text) { if (string.IsNullOrWhiteSpace(text)) diff --git a/src/TelegramPanel.Web/Components/Dialogs/ChannelBatchInviteDialog.razor b/src/TelegramPanel.Web/Components/Dialogs/ChannelBatchInviteDialog.razor index 8f3c67a..71b8db8 100644 --- a/src/TelegramPanel.Web/Components/Dialogs/ChannelBatchInviteDialog.razor +++ b/src/TelegramPanel.Web/Components/Dialogs/ChannelBatchInviteDialog.razor @@ -2,6 +2,7 @@ @inject IChannelService ChannelService @inject ISnackbar Snackbar @inject IDialogService DialogService +@inject TelegramPanel.Web.Services.ChannelInvitePresetsService PresetsService @using TelegramPanel.Core.Interfaces @using TelegramPanel.Data.Entities @@ -17,6 +18,25 @@ 目标频道:@channels.Count 个 + + + + (不使用预设) + @foreach (var p in Presets) + { + @p.Name + } + + + + + 删除预设 + + + + @foreach (var a in accounts) { @@ -27,6 +47,19 @@ + + + + + + + 保存为预设 + + + + @@ -67,6 +100,11 @@ private int delayMs = 2000; private const string UsernamesHelperText = "每行一个 username 或 @username,将按“频道 × 用户”的顺序依次执行。"; + private bool loadingPresets; + private List Presets { get; set; } = new(); + private string selectedPresetName = ""; + private string presetNameToSave = ""; + private bool running; private int total; private int done; @@ -91,6 +129,88 @@ selectedAccountId = DefaultAccountId; else selectedAccountId = accounts.FirstOrDefault()?.Id ?? 0; + + await ReloadPresetsAsync(); + } + + private async Task ReloadPresetsAsync() + { + loadingPresets = true; + try + { + Presets = (await PresetsService.GetPresetsAsync()).ToList(); + } + finally + { + loadingPresets = false; + } + } + + private async Task OnPresetChanged(string? name) + { + selectedPresetName = name ?? ""; + if (string.IsNullOrWhiteSpace(selectedPresetName)) + return; + + var p = Presets.FirstOrDefault(x => string.Equals(x.Name, selectedPresetName, StringComparison.OrdinalIgnoreCase)); + if (p == null) + return; + + usernamesText = string.Join(Environment.NewLine, p.Usernames.Select(x => $"@{x}")); + await Task.CompletedTask; + } + + private async Task SavePreset() + { + var name = (presetNameToSave ?? "").Trim(); + if (string.IsNullOrWhiteSpace(name)) + return; + + var usernames = ParseUsernames(usernamesText); + if (usernames.Count == 0) + { + Snackbar.Add("当前用户名列表为空,无法保存为预设", Severity.Warning); + return; + } + + try + { + await PresetsService.SavePresetAsync(name, usernames); + Snackbar.Add("已保存预设", Severity.Success); + presetNameToSave = ""; + await ReloadPresetsAsync(); + } + catch (Exception ex) + { + Snackbar.Add($"保存预设失败:{ex.Message}", Severity.Error); + } + } + + private async Task DeleteSelectedPreset() + { + if (string.IsNullOrWhiteSpace(selectedPresetName)) + return; + + bool? ok = await DialogService.ShowMessageBox( + title: "删除预设", + message: $"确定删除预设“{selectedPresetName}”吗?", + yesText: "删除", + cancelText: "取消"); + + if (ok != true) + return; + + try + { + await PresetsService.DeletePresetAsync(selectedPresetName); + Snackbar.Add("已删除预设", Severity.Success); + selectedPresetName = ""; + await ReloadPresetsAsync(); + } + catch (Exception ex) + { + Snackbar.Add($"删除预设失败:{ex.Message}", Severity.Error); + } } private async Task Submit() diff --git a/src/TelegramPanel.Web/Components/Dialogs/VersionInfoDialog.razor b/src/TelegramPanel.Web/Components/Dialogs/VersionInfoDialog.razor index 20935dc..f889872 100644 --- a/src/TelegramPanel.Web/Components/Dialogs/VersionInfoDialog.razor +++ b/src/TelegramPanel.Web/Components/Dialogs/VersionInfoDialog.razor @@ -7,14 +7,11 @@ Telegram Panel - 版本:v@VersionService.Version - - - 完整版本:@VersionService.FullVersion + 版本:@VersionService.Version + rel="noopener noreferrer"> GitHub 仓库 @@ -52,7 +49,7 @@ @if (!string.IsNullOrWhiteSpace(_updateInfo.Url)) {
- 查看发布页 + 查看发布页
} @@ -60,7 +57,7 @@ @if (!string.IsNullOrWhiteSpace(_updateInfo.Notes)) { 更新说明(节选) - + @_updateInfo.Notes } diff --git a/src/TelegramPanel.Web/Components/Layout/MainLayout.razor b/src/TelegramPanel.Web/Components/Layout/MainLayout.razor index 40c21f8..d57eb10 100644 --- a/src/TelegramPanel.Web/Components/Layout/MainLayout.razor +++ b/src/TelegramPanel.Web/Components/Layout/MainLayout.razor @@ -11,7 +11,7 @@ Telegram Panel - v@(VersionService.Version) + @VersionService.Version @if (_updateInfo is { Success: true, UpdateAvailable: true } && !string.IsNullOrWhiteSpace(_updateInfo.LatestVersion)) { diff --git a/src/TelegramPanel.Web/Components/Pages/Accounts.razor b/src/TelegramPanel.Web/Components/Pages/Accounts.razor index c14fe16..a5fb1e1 100644 --- a/src/TelegramPanel.Web/Components/Pages/Accounts.razor +++ b/src/TelegramPanel.Web/Components/Pages/Accounts.razor @@ -8,6 +8,7 @@ @inject IDialogService DialogService @inject NavigationManager Navigation @using TelegramPanel.Data.Entities +@using TelegramPanel.Core.Models @using TelegramPanel.Web.Components.Dialogs @implements IDisposable @@ -47,6 +48,14 @@ Disabled="@(loading || selectedAccounts.Count == 0)" OnClick="BatchRefreshTelegramStatus" Size="Size.Small"> 刷新已选状态 + + 清理废号(已选) + + + 清理所有废号 + @@ -118,7 +127,7 @@ @if (telegramStatus.TryGetValue(context.Id, out var s)) { - + @s.Summary } @@ -131,11 +140,11 @@ + OnClick="@(() => ShowDetails(context))" title="查看详情" /> + Disabled="@loading" OnClick="@(() => OpenEditUserProfile(context))" title="编辑用户资料" /> + Disabled="@loading" OnClick="@(() => RefreshTelegramStatus(context))" title="刷新 Telegram 状态" /> 加群/订阅 @@ -146,12 +155,12 @@ 修改二级密码 绑定/换绑找回邮箱 - - @(context.IsActive ? "停用" : "启用") - - 删除账号 + + @(context.IsActive ? "停用" : "启用") + + 删除账号 @@ -953,6 +962,234 @@ } } + private async Task CleanupSelectedWasteAccounts() + { + 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; + } + + loading = true; + try + { + bool? probeResult = await DialogService.ShowMessageBox( + "清理已选废号", + $"将对已选 {targets.Count} 个账号执行 Telegram 状态检测,并删除判定为废号的账号与 session 文件。\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("批量操作风控警告", parameters, options); + var dialogResult = await dialog.Result; + + if (dialogResult.Canceled) + { + loading = 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); + loading = false; + return; + } + } + } + } + + var deleted = 0; + var skipped = 0; + var failures = new List(); + + foreach (var a in targets) + { + try + { + var status = await AccountTelegramTools.RefreshAccountStatusAsync( + a.Id, + probeCreateChannel: probe, + cancellationToken: _pageCts.Token); + telegramStatus[a.Id] = status; + + if (!TelegramAccountWasteJudge.TryGetWasteReason(status, out var reason)) + { + skipped++; + continue; + } + + await AccountManagement.PurgeAccountAsync(a.Id, _pageCts.Token); + deleted++; + } + catch (Exception ex) + { + failures.Add($"{a.DisplayPhone}:{ex.Message}"); + } + + StateHasChanged(); + } + + selectedAccounts.Clear(); + await ReloadTable(); + + var summary = $"清理完成:删除 {deleted},跳过 {skipped},失败 {failures.Count}(共 {targets.Count})"; + Snackbar.Add(summary, failures.Count == 0 ? Severity.Success : Severity.Warning); + + if (failures.Count > 0) + { + var details = string.Join(Environment.NewLine, failures.Take(80)); + if (failures.Count > 80) + details += Environment.NewLine + $"... 仅展示前 80 条(共 {failures.Count} 条)"; + + await DialogService.ShowMessageBox("失败明细(前 80 条)", details, "关闭"); + } + } + catch (Exception ex) + { + Snackbar.Add($"批量清理失败:{ex.Message}", Severity.Error); + } + finally + { + loading = false; + } + } + + private async Task CleanupAllWasteAccounts() + { + if (loading) + return; + + loading = true; + try + { + // 先拿一次总数,用于确认提示(不受页面筛选影响:清理全系统) + var (_, total) = await AccountManagement.QueryAccountsPagedAsync( + categoryId: null, + search: null, + pageIndex: 0, + pageSize: 1, + cancellationToken: _pageCts.Token); + + bool? probeResult = await DialogService.ShowMessageBox( + "清理所有废号", + $"将扫描系统全部账号(共 {total} 个),执行 Telegram 状态检测,并删除判定为废号的账号与 session 文件。\n\n是否进行深度探测(将对每个账号创建并删除一个测试频道,用于判断【创建频道接口是否被冻结】)?", + yesText: "深度检测并清理", + cancelText: "普通检测并清理"); + + var probe = probeResult == true; + + // 全量深度探测风险很高,额外要求确认 + if (probe) + { + bool? ok = await DialogService.ShowMessageBox( + "二次确认(高风险)", + "你选择了【深度探测】并且范围是【全部账号】。\n这会对每个账号创建并删除测试频道,属于高频敏感操作,极易触发风控。\n\n确定继续吗?", + yesText: "继续", + cancelText: "取消"); + + if (ok != true) + return; + } + + var targets = (await AccountManagement.QueryAccountsAsync( + categoryId: null, + search: null, + cancellationToken: _pageCts.Token)) + .Where(a => a.Id > 0) + .GroupBy(a => a.Id) + .Select(g => g.First()) + .ToList(); + + if (targets.Count == 0) + { + Snackbar.Add("系统暂无账号可清理", Severity.Info); + return; + } + + var deleted = 0; + var skipped = 0; + var failures = new List(); + + foreach (var a in targets) + { + try + { + var status = await AccountTelegramTools.RefreshAccountStatusAsync( + a.Id, + probeCreateChannel: probe, + cancellationToken: _pageCts.Token); + telegramStatus[a.Id] = status; + + if (!TelegramAccountWasteJudge.TryGetWasteReason(status, out _)) + { + skipped++; + continue; + } + + await AccountManagement.PurgeAccountAsync(a.Id, _pageCts.Token); + deleted++; + } + catch (Exception ex) + { + failures.Add($"{a.DisplayPhone}:{ex.Message}"); + } + + StateHasChanged(); + } + + selectedAccounts.Clear(); + await ReloadTable(); + + Snackbar.Add( + $"清理完成:删除 {deleted},跳过 {skipped},失败 {failures.Count}(共 {targets.Count})", + failures.Count == 0 ? Severity.Success : Severity.Warning); + + if (failures.Count > 0) + { + var details = string.Join(Environment.NewLine, failures.Take(80)); + if (failures.Count > 80) + details += Environment.NewLine + $"... 仅展示前 80 条(共 {failures.Count} 条)"; + + await DialogService.ShowMessageBox("失败明细(前 80 条)", details, "关闭"); + } + } + catch (Exception ex) + { + Snackbar.Add($"清理失败:{ex.Message}", Severity.Error); + } + finally + { + loading = false; + } + } + private async Task BatchKickAllOtherDevices() { if (loading) diff --git a/src/TelegramPanel.Web/Components/Pages/Bots.razor b/src/TelegramPanel.Web/Components/Pages/Bots.razor index c8f8d71..ef0f16c 100644 --- a/src/TelegramPanel.Web/Components/Pages/Bots.razor +++ b/src/TelegramPanel.Web/Components/Pages/Bots.razor @@ -53,9 +53,15 @@ @context.Categories.Count - - @(context.IsActive ? "启用" : "停用") - + + + + @(context.IsActive ? "启用" : "停用") + + @@ -83,6 +89,7 @@ @code { private bool loading = true; private List bots = new(); + private readonly HashSet togglingBotIds = new(); private string newName = ""; private string newToken = ""; @@ -166,4 +173,29 @@ loading = false; } } + + private async Task SetBotActiveAsync(Bot bot, bool isActive) + { + if (loading) + return; + + if (togglingBotIds.Contains(bot.Id)) + return; + + togglingBotIds.Add(bot.Id); + try + { + await BotManagement.SetBotActiveStatusAsync(bot.Id, isActive); + bot.IsActive = isActive; + Snackbar.Add(isActive ? "已启用 Bot" : "已停用 Bot", Severity.Success); + } + catch (Exception ex) + { + Snackbar.Add($"更新失败:{ex.Message}", Severity.Error); + } + finally + { + togglingBotIds.Remove(bot.Id); + } + } } diff --git a/src/TelegramPanel.Web/Components/Pages/Settings.razor b/src/TelegramPanel.Web/Components/Pages/Settings.razor index ce07348..92e4faf 100644 --- a/src/TelegramPanel.Web/Components/Pages/Settings.razor +++ b/src/TelegramPanel.Web/Components/Pages/Settings.razor @@ -120,6 +120,24 @@ OnClick="@(async (MouseEventArgs _) => await SaveSyncConfig())"> 保存同步设置 + + + + Bot 频道秒级更新 + + 说明:开启后后台会持续轮询 Bot API(getUpdates)并应用 my_chat_member 更新,用于 Bot 被加入/撤权频道后自动出现在“Bot 频道”列表。 + + + @if (botAutoSyncEnabled) + { + + } + + 保存 Bot 自动同步设置 + + 立即同步 @@ -171,7 +189,7 @@ 系统信息 - 版本: 1.0.0 + 版本: @VersionService.Version 运行时: .NET 8.0 数据库: SQLite @@ -194,6 +212,8 @@ private int maxRetries = 3; private bool autoSync = true; private int syncInterval = 6; + private bool botAutoSyncEnabled = false; + private int botAutoSyncIntervalSeconds = 2; private string logLevel = "Information"; private int logRetentionDays = 30; private bool syncing = false; @@ -230,6 +250,8 @@ apiHash = Configuration["Telegram:ApiHash"] ?? ""; autoSync = Configuration.GetValue("Sync:AutoSyncEnabled", false); syncInterval = Configuration.GetValue("Sync:IntervalHours", 6); + botAutoSyncEnabled = Configuration.GetValue("Telegram:BotAutoSyncEnabled", false); + botAutoSyncIntervalSeconds = Configuration.GetValue("Telegram:BotAutoSyncIntervalSeconds", 2); timeZoneId = Configuration["System:TimeZoneId"] ?? ""; } @@ -304,6 +326,31 @@ } } + private async Task SaveBotAutoSyncConfig() + { + if (botAutoSyncEnabled && (botAutoSyncIntervalSeconds < 2 || botAutoSyncIntervalSeconds > 60)) + { + Snackbar.Add("Bot 自动同步轮询间隔范围应为 2-60 秒", Severity.Error); + return; + } + + try + { + var root = await LoadOrCreateLocalConfigAsync(); + var tg = EnsureObject(root, "Telegram"); + tg["BotAutoSyncEnabled"] = botAutoSyncEnabled; + tg["BotAutoSyncIntervalSeconds"] = botAutoSyncIntervalSeconds; + await SaveLocalConfigAsync(root); + + Snackbar.Add("Bot 自动同步设置已保存(重启/自动重载后生效)", Severity.Success); + StateHasChanged(); + } + catch (Exception ex) + { + Snackbar.Add($"保存失败:{ex.Message}(路径:{LocalConfigPath})", Severity.Error); + } + } + private async Task SaveTimeZoneConfig() { try diff --git a/src/TelegramPanel.Web/Program.cs b/src/TelegramPanel.Web/Program.cs index 35021fc..14d9079 100644 --- a/src/TelegramPanel.Web/Program.cs +++ b/src/TelegramPanel.Web/Program.cs @@ -317,6 +317,9 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.Configure(builder.Configuration.GetSection("UpdateCheck")); builder.Services.AddSingleton(); builder.Services.Configure(builder.Configuration.GetSection("System")); diff --git a/src/TelegramPanel.Web/Services/ChannelAdminDefaultsService.cs b/src/TelegramPanel.Web/Services/ChannelAdminDefaultsService.cs new file mode 100644 index 0000000..a3f903e --- /dev/null +++ b/src/TelegramPanel.Web/Services/ChannelAdminDefaultsService.cs @@ -0,0 +1,91 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using Microsoft.AspNetCore.Hosting; +using TelegramPanel.Core.Interfaces; + +namespace TelegramPanel.Web.Services; + +public sealed record ChannelAdminDefaults(AdminRights Rights); + +/// +/// 用户账号“设置管理员”默认权限(保存到 appsettings.local.json) +/// +public sealed class ChannelAdminDefaultsService +{ + private readonly string _configFilePath; + private readonly SemaphoreSlim _writeLock = new(1, 1); + + public ChannelAdminDefaultsService(IConfiguration configuration, IWebHostEnvironment environment) + { + _configFilePath = LocalConfigFile.ResolvePath(configuration, environment); + } + + public async Task GetAsync(CancellationToken cancellationToken = default) + { + try + { + if (!File.Exists(_configFilePath)) + return null; + + var json = await File.ReadAllTextAsync(_configFilePath, cancellationToken); + var root = JsonNode.Parse(json)?.AsObject(); + if (root == null) + return null; + + if (root["ChannelAdminDefaults"] is not JsonObject section) + return null; + + var rightsNode = section["Rights"]; + if (rightsNode is not JsonValue rv) + return null; + + // 允许 int 或 string + int mask; + if (rv.TryGetValue(out var i)) + mask = i; + else if (rv.TryGetValue(out var s) && int.TryParse(s, out var si)) + mask = si; + else + return null; + + var rights = (AdminRights)mask; + return new ChannelAdminDefaults(rights); + } + catch + { + return null; + } + } + + public async Task SaveAsync(ChannelAdminDefaults defaults, CancellationToken cancellationToken = default) + { + if (defaults == null) + throw new ArgumentNullException(nameof(defaults)); + + await _writeLock.WaitAsync(cancellationToken); + try + { + await LocalConfigFile.EnsureExistsAsync(_configFilePath, cancellationToken); + + var json = await File.ReadAllTextAsync(_configFilePath, cancellationToken); + var root = JsonNode.Parse(json)?.AsObject() ?? new JsonObject(); + + var section = root["ChannelAdminDefaults"] as JsonObject ?? new JsonObject(); + section["Rights"] = (int)defaults.Rights; + root["ChannelAdminDefaults"] = section; + + var options = new JsonSerializerOptions + { + WriteIndented = true, + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }; + var updatedJson = JsonSerializer.Serialize(root, options); + await LocalConfigFile.WriteJsonAtomicallyAsync(_configFilePath, updatedJson, cancellationToken); + } + finally + { + _writeLock.Release(); + } + } +} + diff --git a/src/TelegramPanel.Web/Services/ChannelAdminPresetsService.cs b/src/TelegramPanel.Web/Services/ChannelAdminPresetsService.cs new file mode 100644 index 0000000..224b2ae --- /dev/null +++ b/src/TelegramPanel.Web/Services/ChannelAdminPresetsService.cs @@ -0,0 +1,180 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using Microsoft.AspNetCore.Hosting; + +namespace TelegramPanel.Web.Services; + +public sealed record ChannelAdminPreset(string Name, IReadOnlyList Usernames); + +/// +/// 用户账号“设置管理员”预设(保存到 appsettings.local.json,避免手动改 JSON 配置) +/// +public sealed class ChannelAdminPresetsService +{ + private readonly string _configFilePath; + private readonly SemaphoreSlim _writeLock = new(1, 1); + + public ChannelAdminPresetsService(IConfiguration configuration, IWebHostEnvironment environment) + { + _configFilePath = LocalConfigFile.ResolvePath(configuration, environment); + } + + public async Task> GetPresetsAsync(CancellationToken cancellationToken = default) + { + try + { + if (!File.Exists(_configFilePath)) + return Array.Empty(); + + var json = await File.ReadAllTextAsync(_configFilePath, cancellationToken); + var root = JsonNode.Parse(json)?.AsObject(); + if (root == null) + return Array.Empty(); + + if (root["ChannelAdminPresets"] is not JsonObject section) + return Array.Empty(); + + if (section["Presets"] is not JsonObject presetsObj) + return Array.Empty(); + + var list = new List(); + foreach (var kv in presetsObj) + { + var name = (kv.Key ?? "").Trim(); + if (string.IsNullOrWhiteSpace(name)) + continue; + + var usernames = new List(); + if (kv.Value is JsonArray arr) + { + foreach (var node in arr) + { + if (node is not JsonValue v) + continue; + if (!v.TryGetValue(out var s)) + continue; + + var u = (s ?? "").Trim(); + if (u.Length == 0) + continue; + + // 统一为 “username”(不带 @),避免 UI 混乱 + u = u.TrimStart('@'); + if (u.Length == 0) + continue; + + usernames.Add(u); + } + } + + usernames = usernames + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (usernames.Count == 0) + continue; + + list.Add(new ChannelAdminPreset(name, usernames)); + } + + return list + .OrderBy(x => x.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + catch + { + return Array.Empty(); + } + } + + public async Task SavePresetAsync(string name, IReadOnlyList usernames, CancellationToken cancellationToken = default) + { + name = (name ?? "").Trim(); + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("预设名称不能为空", nameof(name)); + + var list = (usernames ?? Array.Empty()) + .Select(x => (x ?? "").Trim().TrimStart('@')) + .Where(x => x.Length > 0) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (list.Count == 0) + throw new ArgumentException("预设用户名不能为空", nameof(usernames)); + + await _writeLock.WaitAsync(cancellationToken); + try + { + await LocalConfigFile.EnsureExistsAsync(_configFilePath, cancellationToken); + + var json = await File.ReadAllTextAsync(_configFilePath, cancellationToken); + var root = JsonNode.Parse(json)?.AsObject() ?? new JsonObject(); + + var section = root["ChannelAdminPresets"] as JsonObject ?? new JsonObject(); + var presetsObj = section["Presets"] as JsonObject ?? new JsonObject(); + + var arr = new JsonArray(); + foreach (var u in list) + arr.Add(u); + + presetsObj[name] = arr; + section["Presets"] = presetsObj; + root["ChannelAdminPresets"] = section; + + var options = new JsonSerializerOptions + { + WriteIndented = true, + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }; + var updatedJson = JsonSerializer.Serialize(root, options); + await LocalConfigFile.WriteJsonAtomicallyAsync(_configFilePath, updatedJson, cancellationToken); + } + finally + { + _writeLock.Release(); + } + } + + public async Task DeletePresetAsync(string name, CancellationToken cancellationToken = default) + { + name = (name ?? "").Trim(); + if (string.IsNullOrWhiteSpace(name)) + return; + + await _writeLock.WaitAsync(cancellationToken); + try + { + if (!File.Exists(_configFilePath)) + return; + + var json = await File.ReadAllTextAsync(_configFilePath, cancellationToken); + var root = JsonNode.Parse(json)?.AsObject(); + if (root == null) + return; + + if (root["ChannelAdminPresets"] is not JsonObject section) + return; + + if (section["Presets"] is not JsonObject presetsObj) + return; + + if (!presetsObj.Remove(name)) + return; + + section["Presets"] = presetsObj; + root["ChannelAdminPresets"] = section; + + var options = new JsonSerializerOptions + { + WriteIndented = true, + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }; + var updatedJson = JsonSerializer.Serialize(root, options); + await LocalConfigFile.WriteJsonAtomicallyAsync(_configFilePath, updatedJson, cancellationToken); + } + finally + { + _writeLock.Release(); + } + } +} diff --git a/src/TelegramPanel.Web/Services/ChannelInvitePresetsService.cs b/src/TelegramPanel.Web/Services/ChannelInvitePresetsService.cs new file mode 100644 index 0000000..91aa4a7 --- /dev/null +++ b/src/TelegramPanel.Web/Services/ChannelInvitePresetsService.cs @@ -0,0 +1,180 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using Microsoft.AspNetCore.Hosting; + +namespace TelegramPanel.Web.Services; + +public sealed record ChannelInvitePreset(string Name, IReadOnlyList Usernames); + +/// +/// 批量邀请用户名预设(保存到 appsettings.local.json) +/// +public sealed class ChannelInvitePresetsService +{ + private readonly string _configFilePath; + private readonly SemaphoreSlim _writeLock = new(1, 1); + + public ChannelInvitePresetsService(IConfiguration configuration, IWebHostEnvironment environment) + { + _configFilePath = LocalConfigFile.ResolvePath(configuration, environment); + } + + public async Task> GetPresetsAsync(CancellationToken cancellationToken = default) + { + try + { + if (!File.Exists(_configFilePath)) + return Array.Empty(); + + var json = await File.ReadAllTextAsync(_configFilePath, cancellationToken); + var root = JsonNode.Parse(json)?.AsObject(); + if (root == null) + return Array.Empty(); + + if (root["ChannelInvitePresets"] is not JsonObject section) + return Array.Empty(); + + if (section["Presets"] is not JsonObject presetsObj) + return Array.Empty(); + + var list = new List(); + foreach (var kv in presetsObj) + { + var name = (kv.Key ?? "").Trim(); + if (string.IsNullOrWhiteSpace(name)) + continue; + + var usernames = new List(); + if (kv.Value is JsonArray arr) + { + foreach (var node in arr) + { + if (node is not JsonValue v) + continue; + if (!v.TryGetValue(out var s)) + continue; + + var u = (s ?? "").Trim(); + if (u.Length == 0) + continue; + + u = u.TrimStart('@'); + if (u.Length == 0) + continue; + + usernames.Add(u); + } + } + + usernames = usernames + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (usernames.Count == 0) + continue; + + list.Add(new ChannelInvitePreset(name, usernames)); + } + + return list + .OrderBy(x => x.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + catch + { + return Array.Empty(); + } + } + + public async Task SavePresetAsync(string name, IReadOnlyList usernames, CancellationToken cancellationToken = default) + { + name = (name ?? "").Trim(); + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("预设名称不能为空", nameof(name)); + + var list = (usernames ?? Array.Empty()) + .Select(x => (x ?? "").Trim().TrimStart('@')) + .Where(x => x.Length > 0) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (list.Count == 0) + throw new ArgumentException("预设用户名不能为空", nameof(usernames)); + + await _writeLock.WaitAsync(cancellationToken); + try + { + await LocalConfigFile.EnsureExistsAsync(_configFilePath, cancellationToken); + + var json = await File.ReadAllTextAsync(_configFilePath, cancellationToken); + var root = JsonNode.Parse(json)?.AsObject() ?? new JsonObject(); + + var section = root["ChannelInvitePresets"] as JsonObject ?? new JsonObject(); + var presetsObj = section["Presets"] as JsonObject ?? new JsonObject(); + + var arr = new JsonArray(); + foreach (var u in list) + arr.Add(u); + + presetsObj[name] = arr; + section["Presets"] = presetsObj; + root["ChannelInvitePresets"] = section; + + var options = new JsonSerializerOptions + { + WriteIndented = true, + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }; + var updatedJson = JsonSerializer.Serialize(root, options); + await LocalConfigFile.WriteJsonAtomicallyAsync(_configFilePath, updatedJson, cancellationToken); + } + finally + { + _writeLock.Release(); + } + } + + public async Task DeletePresetAsync(string name, CancellationToken cancellationToken = default) + { + name = (name ?? "").Trim(); + if (string.IsNullOrWhiteSpace(name)) + return; + + await _writeLock.WaitAsync(cancellationToken); + try + { + if (!File.Exists(_configFilePath)) + return; + + var json = await File.ReadAllTextAsync(_configFilePath, cancellationToken); + var root = JsonNode.Parse(json)?.AsObject(); + if (root == null) + return; + + if (root["ChannelInvitePresets"] is not JsonObject section) + return; + + if (section["Presets"] is not JsonObject presetsObj) + return; + + if (!presetsObj.Remove(name)) + return; + + section["Presets"] = presetsObj; + root["ChannelInvitePresets"] = section; + + var options = new JsonSerializerOptions + { + WriteIndented = true, + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }; + var updatedJson = JsonSerializer.Serialize(root, options); + await LocalConfigFile.WriteJsonAtomicallyAsync(_configFilePath, updatedJson, cancellationToken); + } + finally + { + _writeLock.Release(); + } + } +} +