release: v1.2.3

This commit is contained in:
meoacgx
2025-12-29 18:18:53 +08:00
parent 8817579fbd
commit 1a2cf13422
20 changed files with 1634 additions and 85 deletions

View File

@@ -5,10 +5,10 @@
<!-- 主版本: 重大变更/不兼容更新 -->
<!-- 次版本: 新功能/兼容性更新 -->
<!-- 修订号: Bug修复/小改进 -->
<Version>1.2.2</Version>
<AssemblyVersion>1.2.2.0</AssemblyVersion>
<FileVersion>1.2.2.0</FileVersion>
<InformationalVersion>1.2.2</InformationalVersion>
<Version>1.2.3</Version>
<AssemblyVersion>1.2.3.0</AssemblyVersion>
<FileVersion>1.2.3.0</FileVersion>
<InformationalVersion>1.2.3</InformationalVersion>
<!-- 项目元信息 -->
<Authors>Telegram Panel Contributors</Authors>

View File

@@ -16,7 +16,7 @@
- 🔐 **二级密码2FA与找回邮箱**:支持单个/批量修改二级密码;支持绑定/换绑 2FA 找回邮箱(验证码确认)
- 🧯 **忘记二级密码可申请重置**:支持单个或批量向 Telegram 提交“忘记密码重置”申请(通常等待 7 天后可重新设置)
- 🪪 **账号资料管理**:支持单账号编辑昵称/Bio/用户名/头像;支持批量修改昵称(自动追加手机号后 4 位便于区分)与批量修改 Bio
- 🤖 **Bot 频道管理**:用于管理“频道创建人不在系统中”的频道(把 Bot 设为管理员即可纳入管理),支持批量导出链接、批量设置管理员
- 🤖 **Bot 频道管理**:用于管理“频道创建人不在系统中”的频道(把 Bot 设为管理员即可纳入管理),支持批量导出链接、批量设置管理员;支持对每个 Bot 一键启用/停用(停用后相关后台不再轮询 getUpdates
## 🧊 防冻结指南(新号必看)

View File

@@ -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 的模块/任务使用。

View File

@@ -0,0 +1,103 @@
namespace TelegramPanel.Core.Models;
/// <summary>
/// “废号”判定:用于在批量清理时识别可直接删除的账号。
/// </summary>
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;
}
}

View File

@@ -130,6 +130,63 @@ public class AccountManagementService
}
}
/// <summary>
/// 清理废号:强制尝试删除账号记录与 session 文件。
/// - 删除前会先从 TelegramClientPool 释放客户端,避免 session 被占用
/// - 若仍存在被占用的 session 文件,会重试并在最终失败时抛出异常(用于提示 UI
/// </summary>
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<string>();
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<string> ResolveSessionFileCandidates(Account account)
{
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
@@ -238,6 +307,58 @@ public class AccountManagementService
}
}
private async Task<bool> 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))

View File

@@ -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);

View File

@@ -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<JsonElement>? 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<JsonElement>? 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<bool> IsBotPollingEnabledAsync(CancellationToken cancellationToken)
{
try
{
using var scope = _scopeFactory.CreateScope();
var botRepo = scope.ServiceProvider.GetRequiredService<IBotRepository>();
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<long> FastForwardOffsetAsync(
private sealed record FastForwardResult(long NextOffset, List<JsonElement>? PendingMyChatMember);
private static async Task<FastForwardResult> FastForwardOffsetAndCollectMyChatMemberAsync(
int botId,
string token,
IServiceScopeFactory scopeFactory,
@@ -367,6 +425,47 @@ public sealed class BotUpdateHub : IAsyncDisposable
ILogger logger,
CancellationToken cancellationToken)
{
var pending = new List<JsonElement>();
// 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<string, string?>
{
["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()

View File

@@ -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<Interfaces.AdminRights?> 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<string>();
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
}

View File

@@ -27,18 +27,22 @@ public class AccountRepository : Repository<Account>, 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<Account>, IAccountRepository
return query.OrderByDescending(a => a.Id);
}
private static string NormalizeDigits(string text)
{
if (string.IsNullOrWhiteSpace(text))
return string.Empty;
Span<char> 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<Account?> GetByIdAsync(int id)
{
return await _dbSet

View File

@@ -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 @@
<MudText Typo="Typo.subtitle2">目标频道:@channels.Count 个</MudText>
<MudGrid Spacing="2">
<MudItem xs="12" sm="8">
<MudSelect T="string" Value="@selectedPresetName" ValueChanged="OnPresetChanged"
Label="管理员预设(用户名列表)" Variant="Variant.Outlined" Dense="true" Disabled="@(running || loadingPresets)">
<MudSelectItem Value="@string.Empty">(不使用预设)</MudSelectItem>
@foreach (var p in Presets)
{
<MudSelectItem Value="@p.Name">@p.Name</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="12" sm="4" Class="d-flex align-center">
<MudButton Variant="Variant.Outlined" Color="Color.Error" OnClick="DeleteSelectedPreset"
Disabled="@(running || loadingPresets || string.IsNullOrWhiteSpace(selectedPresetName))">
删除预设
</MudButton>
</MudItem>
</MudGrid>
<MudSelect @bind-Value="selectedAccountId" Label="执行账号" Variant="Variant.Outlined" Dense="true">
@foreach (var a in accounts)
{
@@ -27,31 +48,49 @@
<MudTextField @bind-Value="usernamesText" Label="用户名列表(换行分隔)" Variant="Variant.Outlined" Lines="8"
HelperText="@UsernamesHelperText" />
<MudGrid Spacing="2">
<MudItem xs="12" sm="8">
<MudTextField @bind-Value="presetNameToSave" Label="保存当前用户名为预设组(名称)"
Variant="Variant.Outlined" Disabled="@(running || loadingPresets)" />
</MudItem>
<MudItem xs="12" sm="4" Class="d-flex align-center">
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SavePreset"
Disabled="@(running || loadingPresets || string.IsNullOrWhiteSpace(presetNameToSave))">
保存为预设
</MudButton>
</MudItem>
</MudGrid>
<MudTextField @bind-Value="adminTitle" Label="管理员头衔" Variant="Variant.Outlined"
HelperText="例如Admin/Editor。留空将使用默认值。" />
<MudRadioGroup T="string" @bind-SelectedOption="rightsMode" Row="true">
<MudRadio T="string" Option="basic">常用权限(推荐)</MudRadio>
<MudRadio T="string" Option="full">全权限</MudRadio>
<MudRadio T="string" Option="custom">自定义</MudRadio>
</MudRadioGroup>
<MudText Typo="Typo.subtitle2">权限(可多选)</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">
说明:这里选择的是“你希望目标账号拥有的权限”。如果设置后发现某些权限未生效,面板会提示缺失的权限名称。
</MudText>
@if (rightsMode == "custom")
{
<MudPaper Variant="Variant.Outlined" Class="pa-3">
<MudGrid Spacing="2">
@foreach (var item in CustomRightOptions)
{
<MudItem xs="12" sm="6" md="4">
<MudCheckBox T="bool" Dense="true"
Checked="@HasRight(item.Right)"
CheckedChanged="@((bool v) => SetRight(item.Right, v))"
Label="@item.Label" />
</MudItem>
}
</MudGrid>
</MudPaper>
}
<MudStack Row="true" Spacing="1" Style="flex-wrap: wrap;">
<MudButton Variant="Variant.Outlined" OnClick="@(() => ApplyRights(AdminRights.BasicAdmin))" Disabled="@running">常用权限</MudButton>
<MudButton Variant="Variant.Outlined" OnClick="@(() => ApplyRights(FullRights))" Disabled="@running">全选权限</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Warning" OnClick="@(() => ApplyRights(AdminRights.None))" Disabled="@running">清空</MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveAsDefaultRightsAsync" Disabled="@running">保存为默认权限</MudButton>
</MudStack>
<MudPaper Outlined="true" Class="pa-3">
<MudGrid Spacing="2">
<MudItem xs="12" sm="6" md="4"><MudCheckBox T="bool" Dense="true" @bind-Value="rights.ChangeInfo" Label="修改信息" Disabled="@running" /></MudItem>
<MudItem xs="12" sm="6" md="4"><MudCheckBox T="bool" Dense="true" @bind-Value="rights.PostMessages" Label="发消息" Disabled="@running" /></MudItem>
<MudItem xs="12" sm="6" md="4"><MudCheckBox T="bool" Dense="true" @bind-Value="rights.EditMessages" Label="编辑消息" Disabled="@running" /></MudItem>
<MudItem xs="12" sm="6" md="4"><MudCheckBox T="bool" Dense="true" @bind-Value="rights.DeleteMessages" Label="删除消息" Disabled="@running" /></MudItem>
<MudItem xs="12" sm="6" md="4"><MudCheckBox T="bool" Dense="true" @bind-Value="rights.BanUsers" Label="封禁用户" Disabled="@running" /></MudItem>
<MudItem xs="12" sm="6" md="4"><MudCheckBox T="bool" Dense="true" @bind-Value="rights.InviteUsers" Label="邀请用户" Disabled="@running" /></MudItem>
<MudItem xs="12" sm="6" md="4"><MudCheckBox T="bool" Dense="true" @bind-Value="rights.PinMessages" Label="置顶消息" Disabled="@running" /></MudItem>
<MudItem xs="12" sm="6" md="4"><MudCheckBox T="bool" Dense="true" @bind-Value="rights.ManageCall" Label="管理语音/直播" Disabled="@running" /></MudItem>
<MudItem xs="12" sm="6" md="4"><MudCheckBox T="bool" Dense="true" @bind-Value="rights.AddAdmins" Label="添加管理员" Disabled="@running" /></MudItem>
<MudItem xs="12" sm="6" md="4"><MudCheckBox T="bool" Dense="true" @bind-Value="rights.Anonymous" Label="匿名管理员" Disabled="@running" /></MudItem>
<MudItem xs="12" sm="6" md="4"><MudCheckBox T="bool" Dense="true" @bind-Value="rights.ManageTopics" Label="管理话题" Disabled="@running" /></MudItem>
</MudGrid>
</MudPaper>
<MudNumericField @bind-Value="delayMs" Label="操作间隔(毫秒)" Variant="Variant.Outlined" Min="0" Max="30000"
HelperText="建议设置 1500-4000ms避免触发风控会额外加少量随机抖动。" />
@@ -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<TelegramPanel.Web.Services.ChannelAdminPreset> 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<string> ParseUsernames(string? text)
{
if (string.IsNullOrWhiteSpace(text))

View File

@@ -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 @@
<MudText Typo="Typo.subtitle2">目标频道:@channels.Count 个</MudText>
<MudGrid Spacing="2">
<MudItem xs="12" sm="8">
<MudSelect T="string" Value="@selectedPresetName" ValueChanged="OnPresetChanged"
Label="邀请预设" Variant="Variant.Outlined" Dense="true" Disabled="@(running || loadingPresets)">
<MudSelectItem Value="@string.Empty">(不使用预设)</MudSelectItem>
@foreach (var p in Presets)
{
<MudSelectItem Value="@p.Name">@p.Name</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="12" sm="4" Class="d-flex align-center">
<MudButton Variant="Variant.Outlined" Color="Color.Error" OnClick="DeleteSelectedPreset"
Disabled="@(running || loadingPresets || string.IsNullOrWhiteSpace(selectedPresetName))">
删除预设
</MudButton>
</MudItem>
</MudGrid>
<MudSelect @bind-Value="selectedAccountId" Label="执行账号" Variant="Variant.Outlined" Dense="true">
@foreach (var a in accounts)
{
@@ -27,6 +47,19 @@
<MudTextField @bind-Value="usernamesText" Label="用户名列表(换行分隔)" Variant="Variant.Outlined" Lines="8"
HelperText="@UsernamesHelperText" />
<MudGrid Spacing="2">
<MudItem xs="12" sm="8">
<MudTextField @bind-Value="presetNameToSave" Label="保存当前用户名为预设组(名称)"
Variant="Variant.Outlined" Disabled="@(running || loadingPresets)" />
</MudItem>
<MudItem xs="12" sm="4" Class="d-flex align-center">
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SavePreset"
Disabled="@(running || loadingPresets || string.IsNullOrWhiteSpace(presetNameToSave))">
保存为预设
</MudButton>
</MudItem>
</MudGrid>
<MudNumericField @bind-Value="delayMs" Label="邀请间隔(毫秒)" Variant="Variant.Outlined" Min="0" Max="30000"
HelperText="建议设置 1500-4000ms避免触发风控会额外加少量随机抖动。" />
@@ -67,6 +100,11 @@
private int delayMs = 2000;
private const string UsernamesHelperText = "每行一个 username 或 @username将按“频道 × 用户”的顺序依次执行。";
private bool loadingPresets;
private List<TelegramPanel.Web.Services.ChannelInvitePreset> 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()

View File

@@ -7,14 +7,11 @@
<MudStack Spacing="2">
<MudText Typo="Typo.h6">Telegram Panel</MudText>
<MudText Typo="Typo.body2">
<strong>版本:</strong>v@VersionService.Version
</MudText>
<MudText Typo="Typo.body2" Style="overflow-wrap: anywhere; word-break: break-word; user-select: text;">
<strong>完整版本:</strong>@VersionService.FullVersion
<strong>版本:</strong>@VersionService.Version
</MudText>
<MudLink Href="https://github.com/moeacgx/Telegram-Panel"
Target="_blank"
Rel="noopener noreferrer">
rel="noopener noreferrer">
GitHub 仓库
</MudLink>
@@ -52,7 +49,7 @@
@if (!string.IsNullOrWhiteSpace(_updateInfo.Url))
{
<div style="margin-top: 6px;">
<MudLink Href="@_updateInfo.Url" Target="_blank" Rel="noopener noreferrer">查看发布页</MudLink>
<MudLink Href="@_updateInfo.Url" Target="_blank" rel="noopener noreferrer">查看发布页</MudLink>
</div>
}
</MudAlert>
@@ -60,7 +57,7 @@
@if (!string.IsNullOrWhiteSpace(_updateInfo.Notes))
{
<MudText Typo="Typo.subtitle2">更新说明(节选)</MudText>
<MudPaper Variant="Variant.Outlined" Class="pa-3" Style="max-height: 240px; overflow: auto;">
<MudPaper Outlined="true" Class="pa-3" Style="max-height: 240px; overflow: auto;">
<MudText Typo="Typo.body2" Style="white-space: pre-wrap; user-select: text;">@_updateInfo.Notes</MudText>
</MudPaper>
}

View File

@@ -11,7 +11,7 @@
<MudText Typo="Typo.h5" Class="ml-3">Telegram Panel</MudText>
<MudChip T="string" Size="Size.Small" Color="Color.Default" Class="ml-2" Style="cursor: pointer;"
title="点击查看版本信息" OnClick="@ShowVersionInfo">
v@(VersionService.Version)
@VersionService.Version
</MudChip>
@if (_updateInfo is { Success: true, UpdateAvailable: true } && !string.IsNullOrWhiteSpace(_updateInfo.LatestVersion))
{

View File

@@ -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">
刷新已选状态
</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Error" StartIcon="@Icons.Material.Filled.DeleteSweep"
Disabled="@(loading || selectedAccounts.Count == 0)" OnClick="CleanupSelectedWasteAccounts" Size="Size.Small">
清理废号(已选)
</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Error" StartIcon="@Icons.Material.Filled.DeleteSweep"
Disabled="@loading" OnClick="CleanupAllWasteAccounts" Size="Size.Small">
清理所有废号
</MudButton>
<MudMenu Label="批量操作" Variant="Variant.Outlined" Color="Color.Default" StartIcon="@Icons.Material.Filled.MoreHoriz"
Size="Size.Small" Disabled="@loading" Dense="true" EndIcon="@Icons.Material.Filled.KeyboardArrowDown">
@@ -118,7 +127,7 @@
<MudTd DataLabel="Telegram 状态">
@if (telegramStatus.TryGetValue(context.Id, out var s))
{
<MudChip T="string" Size="Size.Small" Color="@(s.Ok ? Color.Success : Color.Error)" Title="@(BuildTelegramStatusTitle(s))">
<MudChip T="string" Size="Size.Small" Color="@(s.Ok ? Color.Success : Color.Error)" title="@(BuildTelegramStatusTitle(s))">
@s.Summary
</MudChip>
}
@@ -131,11 +140,11 @@
<MudTd DataLabel="操作">
<MudStack Row="true" Spacing="1" AlignItems="AlignItems.Center" Style="flex-wrap: wrap;">
<MudIconButton Icon="@Icons.Material.Filled.Info" Size="Size.Small" Color="Color.Info"
OnClick="@(() => ShowDetails(context))" Title="查看详情" />
OnClick="@(() => ShowDetails(context))" title="查看详情" />
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small" Color="Color.Info"
Disabled="@loading" OnClick="@(() => OpenEditUserProfile(context))" Title="编辑用户资料" />
Disabled="@loading" OnClick="@(() => OpenEditUserProfile(context))" title="编辑用户资料" />
<MudIconButton Icon="@Icons.Material.Filled.Sync" Size="Size.Small" Color="Color.Primary"
Disabled="@loading" OnClick="@(() => RefreshTelegramStatus(context))" Title="刷新 Telegram 状态" />
Disabled="@loading" OnClick="@(() => RefreshTelegramStatus(context))" title="刷新 Telegram 状态" />
<MudMenu Icon="@Icons.Material.Filled.MoreVert" Size="Size.Small" Dense="true">
<MudMenuItem Icon="@Icons.Material.Filled.GroupAdd" Disabled="@loading" OnClick="@(() => OpenJoinSubscribe(context))">加群/订阅</MudMenuItem>
@@ -146,12 +155,12 @@
<MudMenuItem Icon="@Icons.Material.Filled.Password" Disabled="@loading" OnClick="@(() => OpenChangeTwoFactorPassword(context))">修改二级密码</MudMenuItem>
<MudMenuItem Icon="@Icons.Material.Filled.AlternateEmail" Disabled="@loading" OnClick="@(() => OpenChangeTwoFactorRecoveryEmail(context))">绑定/换绑找回邮箱</MudMenuItem>
<MudDivider />
<MudMenuItem Icon="@(context.IsActive ? Icons.Material.Filled.Stop : Icons.Material.Filled.PlayArrow)"
Disabled="@loading"
OnClick="@(() => ToggleActive(context))">
@(context.IsActive ? "停用" : "启用")
</MudMenuItem>
<MudMenuItem Icon="@Icons.Material.Filled.Delete" Disabled="@loading" OnClick="@(() => DeleteAccount(context))">删除账号</MudMenuItem>
<MudMenuItem Icon="@(context.IsActive ? Icons.Material.Filled.Stop : Icons.Material.Filled.PlayArrow)"
Disabled="@loading"
OnClick="@(() => ToggleActive(context))">
@(context.IsActive ? "停用" : "启用")
</MudMenuItem>
<MudMenuItem Icon="@Icons.Material.Filled.Delete" Disabled="@loading" OnClick="@(() => DeleteAccount(context))">删除账号</MudMenuItem>
</MudMenu>
</MudStack>
</MudTd>
@@ -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<RiskWarningDialog>("批量操作风控警告", 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<string>();
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<string>();
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)

View File

@@ -53,9 +53,15 @@
<MudTd DataLabel="分类数">@context.Categories.Count</MudTd>
<MudTd DataLabel="最后同步"><PanelTime Value="context.LastSyncAt" /></MudTd>
<MudTd DataLabel="状态">
<MudChip T="string" Size="Size.Small" Color="@(context.IsActive ? Color.Success : Color.Default)">
@(context.IsActive ? "启用" : "停用")
</MudChip>
<MudStack Row="true" Spacing="1" AlignItems="AlignItems.Center">
<MudSwitch Value="@context.IsActive"
ValueChanged="@(async (bool v) => await SetBotActiveAsync(context, v))"
Disabled="@(loading || togglingBotIds.Contains(context.Id))"
Color="Color.Primary" />
<MudChip T="string" Size="Size.Small" Color="@(context.IsActive ? Color.Success : Color.Default)">
@(context.IsActive ? "启用" : "停用")
</MudChip>
</MudStack>
</MudTd>
<MudTd DataLabel="操作">
<MudMenu Icon="@Icons.Material.Filled.MoreVert" Size="Size.Small">
@@ -83,6 +89,7 @@
@code {
private bool loading = true;
private List<Bot> bots = new();
private readonly HashSet<int> 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);
}
}
}

View File

@@ -120,6 +120,24 @@
OnClick="@(async (MouseEventArgs _) => await SaveSyncConfig())">
保存同步设置
</MudButton>
<MudDivider Class="my-4" />
<MudText Typo="Typo.subtitle2">Bot 频道秒级更新</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mb-2">
说明:开启后后台会持续轮询 Bot APIgetUpdates并应用 my_chat_member 更新,用于 Bot 被加入/撤权频道后自动出现在“Bot 频道”列表。
</MudText>
<MudSwitch @bind-Value="botAutoSyncEnabled" Label="自动同步 Bot 频道(后台)" Color="Color.Primary" />
@if (botAutoSyncEnabled)
{
<MudNumericField @bind-Value="botAutoSyncIntervalSeconds" Label="轮询间隔 (秒)" Variant="Variant.Outlined" Class="mt-3"
Min="2" Max="60" HelperText="建议 2-10 秒;过低可能更容易触发 Telegram 限流。" />
}
<MudButton Variant="Variant.Outlined" Color="Color.Primary" FullWidth="true" Class="mt-3"
OnClick="@(async (MouseEventArgs _) => await SaveBotAutoSyncConfig())">
保存 Bot 自动同步设置
</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Info" FullWidth="true" Class="mt-4"
OnClick="@(async (MouseEventArgs _) => await SyncNow())">
立即同步
@@ -171,7 +189,7 @@
<MudText Typo="Typo.h6">系统信息</MudText>
</MudCardHeader>
<MudCardContent>
<MudText Typo="Typo.body2"><strong>版本:</strong> 1.0.0</MudText>
<MudText Typo="Typo.body2"><strong>版本:</strong> @VersionService.Version</MudText>
<MudText Typo="Typo.body2" Class="mt-2"><strong>运行时:</strong> .NET 8.0</MudText>
<MudText Typo="Typo.body2" Class="mt-2"><strong>数据库:</strong> SQLite</MudText>
<MudDivider Class="my-3" />
@@ -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

View File

@@ -317,6 +317,9 @@ builder.Services.AddScoped<AccountExportService>();
builder.Services.AddScoped<DataSyncService>();
builder.Services.AddScoped<UiPreferencesService>();
builder.Services.AddScoped<BotAdminPresetsService>();
builder.Services.AddScoped<ChannelAdminDefaultsService>();
builder.Services.AddScoped<ChannelAdminPresetsService>();
builder.Services.AddScoped<ChannelInvitePresetsService>();
builder.Services.Configure<UpdateCheckOptions>(builder.Configuration.GetSection("UpdateCheck"));
builder.Services.AddSingleton<UpdateCheckService>();
builder.Services.Configure<PanelTimeZoneOptions>(builder.Configuration.GetSection("System"));

View File

@@ -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);
/// <summary>
/// 用户账号“设置管理员”默认权限(保存到 appsettings.local.json
/// </summary>
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<ChannelAdminDefaults?> 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<int>(out var i))
mask = i;
else if (rv.TryGetValue<string>(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();
}
}
}

View File

@@ -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<string> Usernames);
/// <summary>
/// 用户账号“设置管理员”预设(保存到 appsettings.local.json避免手动改 JSON 配置)
/// </summary>
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<IReadOnlyList<ChannelAdminPreset>> GetPresetsAsync(CancellationToken cancellationToken = default)
{
try
{
if (!File.Exists(_configFilePath))
return Array.Empty<ChannelAdminPreset>();
var json = await File.ReadAllTextAsync(_configFilePath, cancellationToken);
var root = JsonNode.Parse(json)?.AsObject();
if (root == null)
return Array.Empty<ChannelAdminPreset>();
if (root["ChannelAdminPresets"] is not JsonObject section)
return Array.Empty<ChannelAdminPreset>();
if (section["Presets"] is not JsonObject presetsObj)
return Array.Empty<ChannelAdminPreset>();
var list = new List<ChannelAdminPreset>();
foreach (var kv in presetsObj)
{
var name = (kv.Key ?? "").Trim();
if (string.IsNullOrWhiteSpace(name))
continue;
var usernames = new List<string>();
if (kv.Value is JsonArray arr)
{
foreach (var node in arr)
{
if (node is not JsonValue v)
continue;
if (!v.TryGetValue<string>(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<ChannelAdminPreset>();
}
}
public async Task SavePresetAsync(string name, IReadOnlyList<string> usernames, CancellationToken cancellationToken = default)
{
name = (name ?? "").Trim();
if (string.IsNullOrWhiteSpace(name))
throw new ArgumentException("预设名称不能为空", nameof(name));
var list = (usernames ?? Array.Empty<string>())
.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();
}
}
}

View File

@@ -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<string> Usernames);
/// <summary>
/// 批量邀请用户名预设(保存到 appsettings.local.json
/// </summary>
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<IReadOnlyList<ChannelInvitePreset>> GetPresetsAsync(CancellationToken cancellationToken = default)
{
try
{
if (!File.Exists(_configFilePath))
return Array.Empty<ChannelInvitePreset>();
var json = await File.ReadAllTextAsync(_configFilePath, cancellationToken);
var root = JsonNode.Parse(json)?.AsObject();
if (root == null)
return Array.Empty<ChannelInvitePreset>();
if (root["ChannelInvitePresets"] is not JsonObject section)
return Array.Empty<ChannelInvitePreset>();
if (section["Presets"] is not JsonObject presetsObj)
return Array.Empty<ChannelInvitePreset>();
var list = new List<ChannelInvitePreset>();
foreach (var kv in presetsObj)
{
var name = (kv.Key ?? "").Trim();
if (string.IsNullOrWhiteSpace(name))
continue;
var usernames = new List<string>();
if (kv.Value is JsonArray arr)
{
foreach (var node in arr)
{
if (node is not JsonValue v)
continue;
if (!v.TryGetValue<string>(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<ChannelInvitePreset>();
}
}
public async Task SavePresetAsync(string name, IReadOnlyList<string> usernames, CancellationToken cancellationToken = default)
{
name = (name ?? "").Trim();
if (string.IsNullOrWhiteSpace(name))
throw new ArgumentException("预设名称不能为空", nameof(name));
var list = (usernames ?? Array.Empty<string>())
.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();
}
}
}