feat: 任务中心区分用户/Bot任务并新增批量订阅加群

This commit is contained in:
meoacgx committed 2025-12-21 01:23:32 +08:00
1 parent 5c2b59ed02
commit 76496f935e
7 files changed
+689 -7

No files matched your search

@@ -0,0 +1,15 @@
namespace TelegramPanel.Core.BatchTasks;
/// <summary>
/// 批量任务类型常量(数据库中 BatchTask.TaskType 的取值)。
/// </summary>
public static class BatchTaskTypes
{
// Bot 任务(现有)
public const string Invite = "invite";
public const string SetAdmin = "set_admin";
// User 任务(新增)
public const string UserJoinSubscribe = "user_join_subscribe";
}
@@ -635,6 +635,86 @@ public class AccountTelegramToolsService
}
}
/// <summary>
/// 通过链接/用户名加入群组或订阅频道(支持 https://t.me/xxx、t.me/+hash、@username、username、tg://join?invite=hash 等)。
/// </summary>
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;
}
/// <summary>
/// 更新当前账号头像(静态图片)。
/// </summary>
@@ -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
<MudDialog>
<DialogContent>
<MudStack Spacing="2">
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Dense="true">
任务会提交到后台执行,可在【任务中心】查看进度与结果。
</MudAlert>
<MudGrid>
<MudItem xs="12" md="6">
<MudSelect @bind-Value="selectedCategory" Label="任务分类" Variant="Variant.Outlined" Dense="true">
<MudSelectItem Value='@("user")'>用户任务</MudSelectItem>
<MudSelectItem Value='@("bot")'>Bot 任务</MudSelectItem>
</MudSelect>
</MudItem>
<MudItem xs="12" md="6">
<MudSelect @bind-Value="selectedTaskType" Label="任务类型" Variant="Variant.Outlined" Dense="true">
@foreach (var opt in AvailableOptions)
{
<MudSelectItem Value="@opt.TaskType">@opt.DisplayName</MudSelectItem>
}
</MudSelect>
</MudItem>
</MudGrid>
@if (CurrentOption != null && !string.IsNullOrWhiteSpace(CurrentOption.Description))
{
<MudText Typo="Typo.caption" Class="mud-text-secondary">@CurrentOption.Description</MudText>
}
@if (selectedTaskType == BatchTaskTypes.UserJoinSubscribe)
{
<MudDivider Class="my-2" />
<MudAlert Severity="Severity.Warning" Variant="Variant.Outlined" Dense="true">
批量订阅频道/加群(用户任务):支持输入多个链接/用户名,换行分隔。支持:<b>https://t.me/xxx</b>、<b>t.me/+hash</b>、<b>@username</b>、<b>username</b>、<b>tg://join?invite=hash</b>。
</MudAlert>
<MudTextField @bind-Value="linksText" Label="频道/群链接列表(换行分隔)" Variant="Variant.Outlined" Lines="8"
HelperText="每行一个链接或用户名;将对所选账号逐个执行订阅/加入。" />
<MudNumericField @bind-Value="delayMs" Label="操作间隔(毫秒)" Variant="Variant.Outlined" Min="0" Max="10000"
HelperText="建议设置 1500-4000ms,避免触发风控(会额外加少量随机抖动)。" />
<MudStack direction="Row" AlignItems="AlignItems.Center" Spacing="2">
<MudText Typo="Typo.subtitle2">选择执行账号:@selectedAccounts.Count / @accounts.Count</MudText>
<MudSpacer />
<MudButton Variant="Variant.Outlined" Color="Color.Primary" OnClick="SelectAll" Disabled="@(accounts.Count == 0)">全选</MudButton>
<MudButton Variant="Variant.Text" Color="Color.Secondary" OnClick="ClearSelection" Disabled="@(selectedAccounts.Count == 0)">清空</MudButton>
</MudStack>
<MudTextField @bind-Value="accountSearch" Placeholder="搜索账号(手机号/昵称/用户名)" Variant="Variant.Outlined"
Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.Search" Immediate="true" />
<MudTable Items="@FilteredAccounts" Dense="true" Hover="true" Breakpoint="Breakpoint.Sm"
MultiSelection="true" @bind-SelectedItems="selectedAccounts" Loading="@loadingAccounts">
<HeaderContent>
<MudTh>手机号</MudTh>
<MudTh>昵称</MudTh>
<MudTh>用户名</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="手机号">@context.Phone</MudTd>
<MudTd DataLabel="昵称">@(string.IsNullOrWhiteSpace(context.Nickname) ? "-" : context.Nickname)</MudTd>
<MudTd DataLabel="用户名">@(string.IsNullOrWhiteSpace(context.Username) ? "-" : "@" + context.Username)</MudTd>
</RowTemplate>
<NoRecordsContent>
<MudText>暂无账号</MudText>
</NoRecordsContent>
</MudTable>
}
else if (selectedTaskType == BatchTaskTypes.Invite)
{
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Dense="true">
当前版本暂未在任务中心内置“批量邀请”的创建表单,请使用“批量邀请”页面创建任务。
</MudAlert>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="@(() => NavigateTo("/batch/invite"))">
前往批量邀请
</MudButton>
}
else if (selectedTaskType == BatchTaskTypes.SetAdmin)
{
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Dense="true">
当前版本暂未在任务中心内置“设置管理员”的创建表单,请使用“批量设置管理员”页面创建任务。
</MudAlert>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="@(() => NavigateTo("/batch/admins"))">
前往批量设置管理员
</MudButton>
}
</MudStack>
</DialogContent>
<DialogActions>
<MudButton Variant="Variant.Text" Color="Color.Default" OnClick="Cancel" Disabled="@creating">关闭</MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="Create" Disabled="@creating">
@if (creating)
{
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="mr-2" />
<span>提交中...</span>
}
else
{
<span>提交任务</span>
}
</MudButton>
</DialogActions>
</MudDialog>
@code
{
[CascadingParameter] private MudDialogInstance MudDialog { get; set; } = default!;
private sealed record TaskOption(string Category, string TaskType, string DisplayName, string? Description);
private static readonly List<TaskOption> 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<Account> accounts = new();
private string accountSearch = "";
private HashSet<Account> selectedAccounts = new();
private IEnumerable<TaskOption> AvailableOptions => Options.Where(o => o.Category == selectedCategory);
private TaskOption? CurrentOption => Options.FirstOrDefault(o => o.TaskType == selectedTaskType);
private IEnumerable<Account> 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<Account>();
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<TelegramPanel.Web.Components.Dialogs.RiskWarningDialog>("批量操作风控警告", 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<string> ParseLines(string? text)
{
if (string.IsNullOrWhiteSpace(text))
return new List<string>();
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();
}
@@ -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
<MudDialog>
<DialogContent>
<MudStack Spacing="2">
<MudAlert Severity="Severity.Warning" Variant="Variant.Outlined" Dense="true">
将使用指定账号订阅频道/加入群组。支持输入多个链接/用户名,换行分隔。
<MudText Typo="Typo.body2" Class="mt-1">
支持:<b>https://t.me/xxx</b>、<b>t.me/+hash</b>、<b>@username</b>、<b>username</b>、<b>tg://join?invite=hash</b>
</MudText>
</MudAlert>
@if (account == null)
{
<MudText Color="Color.Error">账号不存在或已被删除</MudText>
}
else
{
<MudText Typo="Typo.subtitle2">
执行账号:@account.Phone @(string.IsNullOrWhiteSpace(account.Nickname) ? "" : $"({account.Nickname})")
</MudText>
}
<MudTextField @bind-Value="linksText" Label="频道/群链接列表(换行分隔)" Variant="Variant.Outlined" Lines="8"
HelperText="每行一个链接或用户名;提交后将创建后台任务执行。" />
<MudNumericField @bind-Value="delayMs" Label="操作间隔(毫秒)" Variant="Variant.Outlined" Min="0" Max="10000"
HelperText="建议设置 1500-4000ms,避免触发风控(会额外加少量随机抖动)。" />
</MudStack>
</DialogContent>
<DialogActions>
<MudButton Variant="Variant.Text" Color="Color.Default" OnClick="Cancel" Disabled="@submitting">关闭</MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="Submit" Disabled="@(submitting || account == null)">
@if (submitting)
{
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="mr-2" />
<span>提交中...</span>
}
else
{
<span>确认执行</span>
}
</MudButton>
</DialogActions>
</MudDialog>
@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> { 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<TelegramPanel.Web.Components.Dialogs.RiskWarningDialog>("批量操作风控警告", 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<int> { 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<string> ParseLines(string? text)
{
if (string.IsNullOrWhiteSpace(text))
return new List<string>();
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();
}
@@ -125,6 +125,8 @@
OnClick="@(() => ShowDetails(context))" Title="查看详情" />
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small" Color="Color.Info"
Disabled="@loading" OnClick="@(() => OpenEditUserProfile(context))" Title="编辑用户资料" />
<MudIconButton Icon="@Icons.Material.Filled.GroupAdd" Size="Size.Small" Color="Color.Warning"
Disabled="@loading" OnClick="@(() => OpenJoinSubscribe(context))" Title="进群/订阅" />
<MudIconButton Icon="@Icons.Material.Filled.Sync" Size="Size.Small" Color="Color.Primary"
Disabled="@loading" OnClick="@(() => RefreshTelegramStatus(context))" Title="刷新 Telegram 状态" />
<MudIconButton Icon="@Icons.Material.Filled.MarkEmailUnread" Size="Size.Small" Color="Color.Info"
@@ -288,6 +290,22 @@
await LoadAccounts();
}
private async Task OpenJoinSubscribe(Account account)
{
if (loading)
return;
var parameters = new DialogParameters
{
["AccountId"] = account.Id
};
var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Medium, FullWidth = true };
var dialog = DialogService.Show<TelegramPanel.Web.Components.Dialogs.JoinSubscribeDialog>("进群/订阅", parameters, options)!;
var result = await dialog.Result;
if (result is { Canceled: false })
await LoadAccounts();
}
private async Task OpenBatchChangeTwoFactorPassword()
{
if (loading)
@@ -3,6 +3,7 @@
@inject ISnackbar Snackbar
@inject IDialogService DialogService
@using TelegramPanel.Data.Entities
@using TelegramPanel.Core.BatchTasks
@implements IDisposable
<PageTitle>任务中心 - Telegram Panel</PageTitle>
@@ -17,6 +18,15 @@
<MudSpacer />
<MudIconButton Icon="@Icons.Material.Filled.Refresh" Color="Color.Default" Size="Size.Small"
Disabled="@loading" OnClick="@(async (MouseEventArgs _) => await LoadTasks())" />
<MudButton Variant="Variant.Outlined" Color="Color.Primary" StartIcon="@Icons.Material.Filled.AddTask"
Disabled="@loading" OnClick="OpenCreateTaskDialog" Class="ml-2">
新建任务
</MudButton>
<MudSelect @bind-Value="filterCategory" Label="任务分类" Variant="Variant.Outlined" Dense="true" Class="ml-2" Style="min-width: 140px;">
<MudSelectItem Value='@("all")'>全部</MudSelectItem>
<MudSelectItem Value='@("user")'>用户任务</MudSelectItem>
<MudSelectItem Value='@("bot")'>Bot 任务</MudSelectItem>
</MudSelect>
<MudSelect @bind-Value="filterStatus" Label="状态筛选" Variant="Variant.Outlined" Dense="true" Style="min-width: 120px;">
<MudSelectItem Value='@("all")'>全部</MudSelectItem>
<MudSelectItem Value='@("pending")'>待执行</MudSelectItem>
@@ -27,6 +37,7 @@
</ToolBarContent>
<HeaderContent>
<MudTh>任务ID</MudTh>
<MudTh>归属</MudTh>
<MudTh>任务类型</MudTh>
<MudTh>状态</MudTh>
<MudTh>进度</MudTh>
@@ -36,6 +47,9 @@
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="任务ID">@context.Id</MudTd>
<MudTd DataLabel="归属">
<MudChip T="string" Size="Size.Small" Color="@GetCategoryColor(context.TaskType)">@GetCategoryName(context.TaskType)</MudChip>
</MudTd>
<MudTd DataLabel="任务类型">
<MudChip T="string" Size="Size.Small" Icon="@GetTaskIcon(context.TaskType)">@GetTaskTypeName(context.TaskType)</MudChip>
</MudTd>
@@ -83,12 +97,15 @@
private List<BatchTask> tasks = new();
private bool loading = true;
private string filterStatus = "all";
private string filterCategory = "all";
private PeriodicTimer? refreshTimer;
private CancellationTokenSource? refreshCts;
private IEnumerable<BatchTask> 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<TelegramPanel.Web.Components.Dialogs.CreateBatchTaskDialog>("新建任务", new DialogParameters(), options)!;
var result = await dialog.Result;
if (result is { Canceled: false })
await LoadTasks();
}
}
@@ -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<BatchTaskManagementService>();
var channelManagement = scope.ServiceProvider.GetRequiredService<ChannelManagementService>();
var channelService = scope.ServiceProvider.GetRequiredService<IChannelService>();
var accountTelegramTools = scope.ServiceProvider.GetRequiredService<AccountTelegramToolsService>();
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<UserJoinSubscribeTaskConfig>(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<long> ChannelTelegramIds,
List<string> Usernames,
@@ -260,4 +304,10 @@ public sealed class BatchTaskBackgroundService : BackgroundService
string Title,
int PreferredExecuteAccountId
);
private sealed record UserJoinSubscribeTaskConfig(
List<int> AccountIds,
List<string> Links,
int DelayMs
);
}