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();
+ }
+ }
+}
+