diff --git a/src/TelegramPanel.Core/BatchTasks/BatchTaskTypes.cs b/src/TelegramPanel.Core/BatchTasks/BatchTaskTypes.cs new file mode 100644 index 0000000..7de486d --- /dev/null +++ b/src/TelegramPanel.Core/BatchTasks/BatchTaskTypes.cs @@ -0,0 +1,15 @@ +namespace TelegramPanel.Core.BatchTasks; + +/// +/// 批量任务类型常量(数据库中 BatchTask.TaskType 的取值)。 +/// +public static class BatchTaskTypes +{ + // Bot 任务(现有) + public const string Invite = "invite"; + public const string SetAdmin = "set_admin"; + + // User 任务(新增) + public const string UserJoinSubscribe = "user_join_subscribe"; +} + diff --git a/src/TelegramPanel.Core/Services/Telegram/AccountTelegramToolsService.cs b/src/TelegramPanel.Core/Services/Telegram/AccountTelegramToolsService.cs index fcc8cc4..f9b3564 100644 --- a/src/TelegramPanel.Core/Services/Telegram/AccountTelegramToolsService.cs +++ b/src/TelegramPanel.Core/Services/Telegram/AccountTelegramToolsService.cs @@ -635,6 +635,86 @@ public class AccountTelegramToolsService } } + /// + /// 通过链接/用户名加入群组或订阅频道(支持 https://t.me/xxx、t.me/+hash、@username、username、tg://join?invite=hash 等)。 + /// + public async Task<(bool Success, string? Error, string? JoinedTitle)> JoinChatOrChannelAsync( + int accountId, + string linkOrUsername, + CancellationToken cancellationToken = default) + { + try + { + var raw = (linkOrUsername ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(raw)) + return (false, "链接/用户名为空", null); + + var url = NormalizeTelegramJoinUrl(raw); + + var client = await GetOrCreateConnectedClientAsync(accountId, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + + var chat = await client.AnalyzeInviteLink(url, join: true); + cancellationToken.ThrowIfCancellationRequested(); + + var title = chat switch + { + Channel c => c.title, + Chat c => c.title, + _ => null + }; + + return (true, null, title); + } + catch (RpcException ex) when (ex.Code == 400 && string.Equals(ex.Message, "USER_ALREADY_PARTICIPANT", StringComparison.OrdinalIgnoreCase)) + { + return (true, null, "已在群组/频道中"); + } + catch (Exception ex) + { + var (summary, details) = MapTelegramException(ex); + var msg = string.IsNullOrWhiteSpace(details) ? summary : $"{summary}:{details}"; + return (false, msg, null); + } + } + + private static string NormalizeTelegramJoinUrl(string input) + { + var s = (input ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(s)) + throw new ArgumentException("链接/用户名为空", nameof(input)); + + // tg://join?invite=xxxx + if (s.StartsWith("tg://", StringComparison.OrdinalIgnoreCase)) + { + var inviteKey = "invite="; + var idx = s.IndexOf(inviteKey, StringComparison.OrdinalIgnoreCase); + if (idx >= 0) + { + var hash = s[(idx + inviteKey.Length)..]; + var amp = hash.IndexOf('&'); + if (amp >= 0) + hash = hash[..amp]; + hash = hash.Trim(); + if (!string.IsNullOrWhiteSpace(hash)) + return $"https://t.me/+{hash}"; + } + } + + // 直接是 t.me/xxx + if (s.StartsWith("t.me/", StringComparison.OrdinalIgnoreCase) || s.StartsWith("telegram.me/", StringComparison.OrdinalIgnoreCase)) + return "https://" + s; + + // @username / username + if (s.StartsWith("@", StringComparison.Ordinal)) + s = s.TrimStart('@'); + + if (!s.Contains("://", StringComparison.Ordinal)) + return $"https://t.me/{s}"; + + return s; + } + /// /// 更新当前账号头像(静态图片)。 /// diff --git a/src/TelegramPanel.Web/Components/Dialogs/CreateBatchTaskDialog.razor b/src/TelegramPanel.Web/Components/Dialogs/CreateBatchTaskDialog.razor new file mode 100644 index 0000000..d79026d --- /dev/null +++ b/src/TelegramPanel.Web/Components/Dialogs/CreateBatchTaskDialog.razor @@ -0,0 +1,324 @@ +@namespace TelegramPanel.Web.Components.Dialogs +@inject AccountManagementService AccountManagement +@inject TelegramPanel.Core.Services.AccountRiskService RiskService +@inject BatchTaskManagementService TaskManagement +@inject ISnackbar Snackbar +@inject IDialogService DialogService +@inject NavigationManager Navigation +@using System.Text.Json +@using TelegramPanel.Core.BatchTasks +@using TelegramPanel.Data.Entities + + + + + + 任务会提交到后台执行,可在【任务中心】查看进度与结果。 + + + + + + 用户任务 + Bot 任务 + + + + + @foreach (var opt in AvailableOptions) + { + @opt.DisplayName + } + + + + + @if (CurrentOption != null && !string.IsNullOrWhiteSpace(CurrentOption.Description)) + { + @CurrentOption.Description + } + + @if (selectedTaskType == BatchTaskTypes.UserJoinSubscribe) + { + + + + 批量订阅频道/加群(用户任务):支持输入多个链接/用户名,换行分隔。支持:https://t.me/xxx、t.me/+hash、@username、username、tg://join?invite=hash。 + + + + + + + + 选择执行账号:@selectedAccounts.Count / @accounts.Count + + 全选 + 清空 + + + + + + + 手机号 + 昵称 + 用户名 + + + @context.Phone + @(string.IsNullOrWhiteSpace(context.Nickname) ? "-" : context.Nickname) + @(string.IsNullOrWhiteSpace(context.Username) ? "-" : "@" + context.Username) + + + 暂无账号 + + + } + else if (selectedTaskType == BatchTaskTypes.Invite) + { + + 当前版本暂未在任务中心内置“批量邀请”的创建表单,请使用“批量邀请”页面创建任务。 + + + 前往批量邀请 + + } + else if (selectedTaskType == BatchTaskTypes.SetAdmin) + { + + 当前版本暂未在任务中心内置“设置管理员”的创建表单,请使用“批量设置管理员”页面创建任务。 + + + 前往批量设置管理员 + + } + + + + 关闭 + + @if (creating) + { + + 提交中... + } + else + { + 提交任务 + } + + + + +@code +{ + [CascadingParameter] private MudDialogInstance MudDialog { get; set; } = default!; + + private sealed record TaskOption(string Category, string TaskType, string DisplayName, string? Description); + + private static readonly List Options = new() + { + new("bot", BatchTaskTypes.Invite, "批量邀请", "对所选频道批量邀请用户名列表(Bot/执行账号需具备权限)。"), + new("bot", BatchTaskTypes.SetAdmin, "设置管理员", "对所选频道批量设置管理员(Bot/执行账号需具备权限)。"), + new("user", BatchTaskTypes.UserJoinSubscribe, "批量订阅/加群", "用户账号批量加入群组/订阅频道(Bot 无法主动加入)。") + }; + + private string _selectedCategory = "user"; + private string selectedCategory + { + get => _selectedCategory; + set + { + if (string.Equals(_selectedCategory, value, StringComparison.Ordinal)) + return; + _selectedCategory = value; + EnsureTaskTypeValid(); + } + } + private string selectedTaskType = BatchTaskTypes.UserJoinSubscribe; + + private bool creating; + + // join/subscribe config + private string linksText = ""; + private int delayMs = 2000; + + private bool loadingAccounts; + private List accounts = new(); + private string accountSearch = ""; + private HashSet selectedAccounts = new(); + + private IEnumerable AvailableOptions => Options.Where(o => o.Category == selectedCategory); + private TaskOption? CurrentOption => Options.FirstOrDefault(o => o.TaskType == selectedTaskType); + + private IEnumerable FilteredAccounts => + accounts.Where(a => + { + if (string.IsNullOrWhiteSpace(accountSearch)) + return true; + var q = accountSearch.Trim(); + return a.Phone.Contains(q, StringComparison.OrdinalIgnoreCase) + || (a.Nickname?.Contains(q, StringComparison.OrdinalIgnoreCase) ?? false) + || (a.Username?.Contains(q, StringComparison.OrdinalIgnoreCase) ?? false); + }); + + protected override async Task OnInitializedAsync() + { + EnsureTaskTypeValid(); + await LoadAccounts(); + } + + private async Task LoadAccounts() + { + loadingAccounts = true; + try + { + var list = await AccountManagement.GetActiveAccountsAsync(); + accounts = list.OrderBy(a => a.Id).ToList(); + selectedAccounts = selectedAccounts.Where(a => accounts.Any(x => x.Id == a.Id)).ToHashSet(); + } + catch (Exception ex) + { + Snackbar.Add($"加载账号失败:{ex.Message}", Severity.Error); + } + finally + { + loadingAccounts = false; + } + } + + private void EnsureTaskTypeValid() + { + if (AvailableOptions.All(o => o.TaskType != selectedTaskType)) + { + selectedTaskType = AvailableOptions.FirstOrDefault()?.TaskType + ?? Options.First().TaskType; + } + } + + private void SelectAll() => selectedAccounts = accounts.ToHashSet(); + private void ClearSelection() => selectedAccounts = new HashSet(); + + private async Task Create() + { + if (creating) + return; + + EnsureTaskTypeValid(); + + if (selectedTaskType != BatchTaskTypes.UserJoinSubscribe) + { + Snackbar.Add("当前任务类型暂不支持从任务中心直接创建", Severity.Info); + return; + } + + var links = ParseLines(linksText); + if (links.Count == 0) + { + Snackbar.Add("请至少输入一个频道/群链接", Severity.Warning); + return; + } + + var selected = selectedAccounts.ToList(); + if (selected.Count == 0) + { + Snackbar.Add("请至少选择一个执行账号", Severity.Warning); + return; + } + + // 批量风控检查:加入群/订阅频道是敏感操作 + var batchCheck = RiskService.CheckBatchAccounts(selected); + 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 result = await dialog.Result; + + if (result.Canceled) + return; + + if (result.Data is TelegramPanel.Core.Services.RiskWarningAction action && action == TelegramPanel.Core.Services.RiskWarningAction.ExcludeRisky) + { + selected = batchCheck.SafeAccounts.ToList(); + if (selected.Count == 0) + { + Snackbar.Add("排除风险账号后无剩余账号", Severity.Info); + return; + } + } + } + + bool? confirm = await DialogService.ShowMessageBox( + "确认提交任务", + $"将对 {selected.Count} 个账号执行 {links.Count} 个链接的订阅/加群操作,总计 {selected.Count * links.Count} 次。是否继续?", + yesText: "继续", cancelText: "取消"); + + if (confirm != true) + return; + + creating = true; + try + { + var ids = selected.Select(a => a.Id).Distinct().ToList(); + var task = new BatchTask + { + TaskType = BatchTaskTypes.UserJoinSubscribe, + Total = ids.Count * links.Count, + Completed = 0, + Failed = 0, + Config = JsonSerializer.Serialize(new + { + AccountIds = ids, + Links = links, + DelayMs = delayMs + }) + }; + + var created = await TaskManagement.CreateTaskAsync(task); + Snackbar.Add($"已提交后台任务 #{created.Id}(批量订阅/加群)。请到【任务中心】查看进度。", Severity.Success); + MudDialog.Close(DialogResult.Ok(created.Id)); + } + catch (Exception ex) + { + Snackbar.Add($"提交失败:{ex.Message}", Severity.Error); + } + finally + { + creating = false; + } + } + + private void NavigateTo(string url) + { + MudDialog.Close(); + Navigation.NavigateTo(url); + } + + private static List ParseLines(string? text) + { + if (string.IsNullOrWhiteSpace(text)) + return new List(); + + return text + .Split(new[] { "\r\n", "\n", "\r" }, StringSplitOptions.RemoveEmptyEntries) + .Select(x => (x ?? string.Empty).Trim()) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + private void Cancel() => MudDialog.Close(); +} diff --git a/src/TelegramPanel.Web/Components/Dialogs/JoinSubscribeDialog.razor b/src/TelegramPanel.Web/Components/Dialogs/JoinSubscribeDialog.razor new file mode 100644 index 0000000..ae6175d --- /dev/null +++ b/src/TelegramPanel.Web/Components/Dialogs/JoinSubscribeDialog.razor @@ -0,0 +1,158 @@ +@namespace TelegramPanel.Web.Components.Dialogs +@inject AccountManagementService AccountManagement +@inject TelegramPanel.Core.Services.AccountRiskService RiskService +@inject BatchTaskManagementService TaskManagement +@inject ISnackbar Snackbar +@inject IDialogService DialogService +@using System.Text.Json +@using TelegramPanel.Core.BatchTasks +@using TelegramPanel.Data.Entities + + + + + + 将使用指定账号订阅频道/加入群组。支持输入多个链接/用户名,换行分隔。 + + 支持:https://t.me/xxx、t.me/+hash、@username、username、tg://join?invite=hash + + + + @if (account == null) + { + 账号不存在或已被删除 + } + else + { + + 执行账号:@account.Phone @(string.IsNullOrWhiteSpace(account.Nickname) ? "" : $"({account.Nickname})") + + } + + + + + + + + 关闭 + + @if (submitting) + { + + 提交中... + } + else + { + 确认执行 + } + + + + +@code +{ + [CascadingParameter] private MudDialogInstance MudDialog { get; set; } = default!; + [Parameter] public int AccountId { get; set; } + + private Account? account; + private string linksText = ""; + private int delayMs = 2000; + private bool submitting; + + protected override async Task OnInitializedAsync() + { + if (AccountId <= 0) + return; + + account = await AccountManagement.GetAccountAsync(AccountId); + } + + private async Task Submit() + { + if (submitting || account == null) + return; + + var links = ParseLines(linksText); + if (links.Count == 0) + { + Snackbar.Add("请至少输入一个频道/群链接", Severity.Warning); + return; + } + + // 风控检查:加入群/订阅频道是敏感操作 + var batchCheck = RiskService.CheckBatchAccounts(new List { account }); + if (batchCheck.HasRiskyAccounts) + { + var parameters = new DialogParameters + { + ["Message"] = $"检测到 {batchCheck.RiskyCount} 个风险账号", + ["DetailedMessage"] = batchCheck.GetRiskySummary(), + ["ShowRecommendations"] = true, + ["ShowExcludeOption"] = false, + ["RiskyAccounts"] = batchCheck.RiskyAccounts.ToList() + }; + var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Large }; + var dialog = DialogService.Show("批量操作风控警告", parameters, options); + var result = await dialog.Result; + if (result.Canceled) + return; + } + + bool? confirm = await DialogService.ShowMessageBox( + "确认执行", + $"将使用账号 {account.Phone} 对 {links.Count} 个链接执行订阅/加群操作。是否继续?", + yesText: "继续", cancelText: "取消"); + + if (confirm != true) + return; + + submitting = true; + try + { + var task = new BatchTask + { + TaskType = BatchTaskTypes.UserJoinSubscribe, + Total = links.Count, + Completed = 0, + Failed = 0, + Config = JsonSerializer.Serialize(new + { + AccountIds = new List { account.Id }, + Links = links, + DelayMs = delayMs + }) + }; + + var created = await TaskManagement.CreateTaskAsync(task); + Snackbar.Add($"已提交后台任务 #{created.Id}(订阅/加群)。请到【任务中心】查看进度。", Severity.Success); + MudDialog.Close(DialogResult.Ok(created.Id)); + } + catch (Exception ex) + { + Snackbar.Add($"提交失败:{ex.Message}", Severity.Error); + } + finally + { + submitting = false; + } + } + + private static List ParseLines(string? text) + { + if (string.IsNullOrWhiteSpace(text)) + return new List(); + + return text + .Split(new[] { "\r\n", "\n", "\r" }, StringSplitOptions.RemoveEmptyEntries) + .Select(x => (x ?? string.Empty).Trim()) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + private void Cancel() => MudDialog.Close(); +} + diff --git a/src/TelegramPanel.Web/Components/Pages/Accounts.razor b/src/TelegramPanel.Web/Components/Pages/Accounts.razor index 303c058..422ae96 100644 --- a/src/TelegramPanel.Web/Components/Pages/Accounts.razor +++ b/src/TelegramPanel.Web/Components/Pages/Accounts.razor @@ -125,6 +125,8 @@ OnClick="@(() => ShowDetails(context))" Title="查看详情" /> + ("进群/订阅", parameters, options)!; + var result = await dialog.Result; + if (result is { Canceled: false }) + await LoadAccounts(); + } + private async Task OpenBatchChangeTwoFactorPassword() { if (loading) diff --git a/src/TelegramPanel.Web/Components/Pages/Tasks.razor b/src/TelegramPanel.Web/Components/Pages/Tasks.razor index 6162a0b..19b54f7 100644 --- a/src/TelegramPanel.Web/Components/Pages/Tasks.razor +++ b/src/TelegramPanel.Web/Components/Pages/Tasks.razor @@ -3,6 +3,7 @@ @inject ISnackbar Snackbar @inject IDialogService DialogService @using TelegramPanel.Data.Entities +@using TelegramPanel.Core.BatchTasks @implements IDisposable 任务中心 - Telegram Panel @@ -17,6 +18,15 @@ + + 新建任务 + + + 全部 + 用户任务 + Bot 任务 + 全部 待执行 @@ -27,6 +37,7 @@ 任务ID + 归属 任务类型 状态 进度 @@ -36,6 +47,9 @@ @context.Id + + @GetCategoryName(context.TaskType) + @GetTaskTypeName(context.TaskType) @@ -83,12 +97,15 @@ private List tasks = new(); private bool loading = true; private string filterStatus = "all"; + private string filterCategory = "all"; private PeriodicTimer? refreshTimer; private CancellationTokenSource? refreshCts; private IEnumerable filteredTasks => - filterStatus == "all" ? tasks : tasks.Where(t => t.Status == filterStatus); + tasks.Where(t => + (filterStatus == "all" || t.Status == filterStatus) && + (filterCategory == "all" || GetCategory(t.TaskType) == filterCategory)); protected override async Task OnInitializedAsync() { @@ -154,17 +171,28 @@ return (double)task.Completed / task.Total * 100; } + private static string GetCategory(string taskType) => + taskType == BatchTaskTypes.UserJoinSubscribe ? "user" : "bot"; + + private static string GetCategoryName(string taskType) => + GetCategory(taskType) == "user" ? "用户" : "Bot"; + + private static Color GetCategoryColor(string taskType) => + GetCategory(taskType) == "user" ? Color.Info : Color.Secondary; + private string GetTaskIcon(string type) => type switch { - "invite" => Icons.Material.Filled.PersonAdd, - "set_admin" => Icons.Material.Filled.AdminPanelSettings, + BatchTaskTypes.Invite => Icons.Material.Filled.PersonAdd, + BatchTaskTypes.SetAdmin => Icons.Material.Filled.AdminPanelSettings, + BatchTaskTypes.UserJoinSubscribe => Icons.Material.Filled.GroupAdd, _ => Icons.Material.Filled.Task }; private string GetTaskTypeName(string type) => type switch { - "invite" => "批量邀请", - "set_admin" => "设置管理员", + BatchTaskTypes.Invite => "批量邀请", + BatchTaskTypes.SetAdmin => "设置管理员", + BatchTaskTypes.UserJoinSubscribe => "批量订阅/加群", _ => type }; @@ -258,4 +286,13 @@ } } } + + private async Task OpenCreateTaskDialog() + { + var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Large, FullWidth = true }; + var dialog = DialogService.Show("新建任务", new DialogParameters(), options)!; + var result = await dialog.Result; + if (result is { Canceled: false }) + await LoadTasks(); + } } diff --git a/src/TelegramPanel.Web/Services/BatchTaskBackgroundService.cs b/src/TelegramPanel.Web/Services/BatchTaskBackgroundService.cs index 9a14c44..befa9d9 100644 --- a/src/TelegramPanel.Web/Services/BatchTaskBackgroundService.cs +++ b/src/TelegramPanel.Web/Services/BatchTaskBackgroundService.cs @@ -2,8 +2,10 @@ using System.Text.Json; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using TelegramPanel.Core.BatchTasks; using TelegramPanel.Core.Interfaces; using TelegramPanel.Core.Services; +using TelegramPanel.Core.Services.Telegram; using TelegramPanel.Data.Entities; namespace TelegramPanel.Web.Services; @@ -71,6 +73,7 @@ public sealed class BatchTaskBackgroundService : BackgroundService var taskManagement = scope.ServiceProvider.GetRequiredService(); var channelManagement = scope.ServiceProvider.GetRequiredService(); var channelService = scope.ServiceProvider.GetRequiredService(); + var accountTelegramTools = scope.ServiceProvider.GetRequiredService(); var pending = (await taskManagement.GetTasksByStatusAsync("pending")) .OrderBy(t => t.CreatedAt) @@ -89,12 +92,15 @@ public sealed class BatchTaskBackgroundService : BackgroundService { switch (pending.TaskType) { - case "invite": + case BatchTaskTypes.Invite: (completed, failed) = await RunInviteAsync(pending, taskManagement, channelManagement, channelService, completed, failed, cancellationToken); break; - case "set_admin": + case BatchTaskTypes.SetAdmin: (completed, failed) = await RunSetAdminAsync(pending, taskManagement, channelManagement, channelService, completed, failed, cancellationToken); break; + case BatchTaskTypes.UserJoinSubscribe: + (completed, failed) = await RunUserJoinSubscribeAsync(pending, taskManagement, accountTelegramTools, completed, failed, cancellationToken); + break; default: failed = pending.Total == 0 ? 1 : pending.Total; await taskManagement.UpdateTaskProgressAsync(pending.Id, completed, failed); @@ -246,6 +252,44 @@ public sealed class BatchTaskBackgroundService : BackgroundService return (completed, failed); } + private static async Task<(int completed, int failed)> RunUserJoinSubscribeAsync( + BatchTask task, + BatchTaskManagementService taskManagement, + AccountTelegramToolsService accountTelegramTools, + int completed, + int failed, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(task.Config)) + throw new InvalidOperationException("任务配置为空"); + + var cfg = JsonSerializer.Deserialize(task.Config); + if (cfg == null || cfg.AccountIds.Count == 0 || cfg.Links.Count == 0) + throw new InvalidOperationException("任务配置缺少 AccountIds 或 Links"); + + foreach (var accountId in cfg.AccountIds) + { + foreach (var link in cfg.Links) + { + cancellationToken.ThrowIfCancellationRequested(); + + var latest = await taskManagement.GetTaskAsync(task.Id); + if (latest != null && latest.Status != "running") + return (completed, failed); + + var (ok, _, _) = await accountTelegramTools.JoinChatOrChannelAsync(accountId, link, cancellationToken); + if (ok) completed++; else failed++; + + await taskManagement.UpdateTaskProgressAsync(task.Id, completed, failed); + + if (cfg.DelayMs > 0) + await Task.Delay(cfg.DelayMs + Random.Shared.Next(300, 900), cancellationToken); + } + } + + return (completed, failed); + } + private sealed record InviteTaskConfig( List ChannelTelegramIds, List Usernames, @@ -260,4 +304,10 @@ public sealed class BatchTaskBackgroundService : BackgroundService string Title, int PreferredExecuteAccountId ); + + private sealed record UserJoinSubscribeTaskConfig( + List AccountIds, + List Links, + int DelayMs + ); }