Files
Telegram-Panel/src/TelegramPanel.Web/Components/Dialogs/BotChannelBatchInviteDialog.razor
T
2026-01-03 13:14:37 +08:00

536 lines
20 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@inject AccountManagementService AccountManagement
@inject IChannelService ChannelService
@inject BotTelegramService BotTelegram
@inject AccountTelegramToolsService AccountTools
@inject ISnackbar Snackbar
@inject IDialogService DialogService
@inject TelegramPanel.Web.Services.ChannelInvitePresetsService PresetsService
@using TelegramPanel.Data.Entities
<MudDialog>
<DialogContent>
<MudStack Spacing="2">
<MudAlert Severity="Severity.Warning" Variant="Variant.Outlined" Dense="true">
<span>将使用“执行账号”邀请用户加入所选 Bot 频道。执行账号必须是该频道管理员(通过 Bot API 获取管理员列表并与系统账号匹配)。</span>
<MudText Typo="Typo.body2" Class="mt-1">
支持:<b>@@username</b>、<b>username</b>
</MudText>
</MudAlert>
<MudText Typo="Typo.subtitle2">目标频道:@channels.Count 个</MudText>
@if (loadingAdmins)
{
<MudProgressLinear Indeterminate="true" />
<MudText Typo="Typo.caption" Color="Color.Secondary">正在读取频道管理员列表(Bot API)...</MudText>
}
@if (!string.IsNullOrWhiteSpace(adminSummary))
{
<MudText Typo="Typo.caption" Color="Color.Secondary">@adminSummary</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"
Disabled="@(running || loadingAdmins || eligibleAccounts.Count == 0)">
<MudSelectItem Value="0">自动选择(按频道)</MudSelectItem>
@foreach (var a in eligibleAccounts)
{
<MudSelectItem Value="@a.Id">@FormatAccountLabel(a)</MudSelectItem>
}
</MudSelect>
@if (eligibleAccounts.Count == 0 && !loadingAdmins && channels.Count > 0)
{
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Dense="true">
当前所选频道中未发现“既是频道管理员又在系统中的账号”。你仍可继续执行,但这些频道会被跳过。
</MudAlert>
}
<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,避免触发风控(会额外加少量随机抖动)。" />
@if (running)
{
<MudDivider />
<MudText Typo="Typo.body2">进度:@done / @total(失败:@failed)</MudText>
<MudProgressLinear Value="@Progress" Color="Color.Primary" />
@if (!string.IsNullOrWhiteSpace(currentHint))
{
<MudText Typo="Typo.caption" Class="mud-text-secondary">@currentHint</MudText>
}
}
</MudStack>
</DialogContent>
<DialogActions>
<MudButton Variant="Variant.Text" Color="Color.Default" OnClick="CancelOrClose">
@(running ? "停止" : "关闭")
</MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="Submit" Disabled="@(running || channels.Count == 0)">
开始邀请
</MudButton>
</DialogActions>
</MudDialog>
@code
{
[CascadingParameter] private MudDialogInstance MudDialog { get; set; } = default!;
[Parameter] public int BotId { get; set; }
[Parameter] public List<BotChannel> Channels { get; set; } = new();
private readonly Random _rand = new();
private readonly List<Account> eligibleAccounts = new();
private readonly Dictionary<long, Account> accountsByUserId = new();
private readonly Dictionary<long, List<BotTelegramService.BotChatAdminInfo>> channelAdmins = new();
private List<BotChannel> channels = new();
private int selectedAccountId;
private string usernamesText = "";
private int delayMs = 2000;
private const string UsernamesHelperText = "每行一个 username 或 @username,将按“频道 × 用户”的顺序依次执行。";
private bool loadingAdmins;
private string? adminSummary;
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;
private int failed;
private string? currentHint;
private CancellationTokenSource? _cts;
private int Progress => total <= 0 ? 0 : (int)Math.Round(done * 100d / total);
protected override async Task OnInitializedAsync()
{
channels = (Channels ?? new List<BotChannel>())
.Where(x => x != null)
.GroupBy(x => x.TelegramId)
.Select(x => x.First())
.ToList();
await LoadAccountsAsync();
await ReloadPresetsAsync();
if (BotId > 0 && channels.Count > 0)
await LoadAdminsAndEligibleAccountsAsync();
}
private async Task LoadAccountsAsync()
{
var all = (await AccountManagement.GetAllAccountsAsync()).ToList();
accountsByUserId.Clear();
foreach (var a in all)
{
if (!a.IsActive)
continue;
if (a.Category?.ExcludeFromOperations == true)
continue;
if (a.UserId <= 0)
continue;
if (!accountsByUserId.ContainsKey(a.UserId))
accountsByUserId[a.UserId] = a;
}
}
private async Task LoadAdminsAndEligibleAccountsAsync()
{
loadingAdmins = true;
adminSummary = null;
eligibleAccounts.Clear();
channelAdmins.Clear();
try
{
var eligibleUserIds = new HashSet<long>();
var matchedChannels = 0;
foreach (var ch in channels)
{
var admins = new List<BotTelegramService.BotChatAdminInfo>();
try
{
admins = await BotTelegram.GetChatAdminsAsync(BotId, ch.TelegramId, CancellationToken.None);
foreach (var a in admins)
{
if (accountsByUserId.ContainsKey(a.UserId))
eligibleUserIds.Add(a.UserId);
}
if (admins.Any(a => accountsByUserId.ContainsKey(a.UserId)))
matchedChannels++;
}
catch
{
// ignore: 无权限/风控/频道已失效等,后续按“无可用执行账号”处理
}
channelAdmins[ch.TelegramId] = admins;
}
foreach (var uid in eligibleUserIds)
{
if (accountsByUserId.TryGetValue(uid, out var acc))
eligibleAccounts.Add(acc);
}
eligibleAccounts.Sort((a, b) => string.Compare(FormatAccountLabel(a), FormatAccountLabel(b), StringComparison.OrdinalIgnoreCase));
adminSummary = $"可用执行账号:{eligibleAccounts.Count} 个;可执行频道:{matchedChannels}/{channels.Count}。";
}
finally
{
loadingAdmins = false;
}
}
private async Task ReloadPresetsAsync()
{
loadingPresets = true;
try
{
Presets = (await PresetsService.GetPresetsAsync()).ToList();
}
finally
{
loadingPresets = false;
}
}
private Task OnPresetChanged(string value)
{
selectedPresetName = value ?? "";
if (string.IsNullOrWhiteSpace(selectedPresetName))
return Task.CompletedTask;
var preset = Presets.FirstOrDefault(x => string.Equals(x.Name, selectedPresetName, StringComparison.OrdinalIgnoreCase));
if (preset != null)
usernamesText = string.Join(Environment.NewLine, preset.Usernames);
return Task.CompletedTask;
}
private async Task SavePreset()
{
var name = (presetNameToSave ?? "").Trim();
if (string.IsNullOrWhiteSpace(name))
{
Snackbar.Add("请输入预设名称", Severity.Warning);
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()
{
if (running)
return;
if (BotId <= 0)
{
Snackbar.Add("BotId 无效", Severity.Error);
return;
}
var usernames = ParseUsernames(usernamesText);
if (usernames.Count == 0)
{
Snackbar.Add("请至少输入一个用户名", Severity.Warning);
return;
}
if (channels.Count == 0)
{
Snackbar.Add("未选择任何频道", Severity.Info);
return;
}
bool? confirm = await DialogService.ShowMessageBox(
"确认执行",
$"邀请:{channels.Count} 个频道 × {usernames.Count} 个用户(共 {channels.Count * usernames.Count} 次操作)。是否继续?",
yesText: "继续", cancelText: "取消");
if (confirm != true)
return;
running = true;
total = channels.Count * usernames.Count;
done = 0;
failed = 0;
currentHint = null;
_cts = new CancellationTokenSource();
var failures = new List<string>();
try
{
var joinTried = new HashSet<string>(StringComparer.Ordinal);
foreach (var ch in channels)
{
var (executorId, reason) = ResolveExecutorAccountId(ch);
if (executorId == null)
{
foreach (var _ in usernames)
{
done++;
failed++;
}
failures.Add($"{ch.Title}:{reason ?? "无可用执行账号"}");
StateHasChanged();
continue;
}
foreach (var username in usernames)
{
_cts.Token.ThrowIfCancellationRequested();
currentHint = $"{ch.Title} => {username}";
StateHasChanged();
try
{
var result = await ChannelService.InviteUserAsync(executorId.Value, ch.TelegramId, username);
if (!result.Success)
{
failed++;
failures.Add($"{ch.Title} => {username}:{result.Error}");
}
}
catch (Exception ex) when (!joinTried.Contains($"{executorId.Value}:{ch.TelegramId}") && BotChannelJoinRetryHelper.LooksLikeChannelNotFound(ex.Message))
{
var key = $"{executorId.Value}:{ch.TelegramId}";
joinTried.Add(key);
var joined = await BotChannelJoinRetryHelper.TryJoinChannelAsync(
BotTelegram, AccountTools, BotId, executorId.Value, ch, failures, _cts.Token);
if (!joined)
{
failed++;
failures.Add($"{ch.Title} => {username}:{ex.Message}");
continue;
}
try
{
var result = await ChannelService.InviteUserAsync(executorId.Value, ch.TelegramId, username);
if (!result.Success)
{
failed++;
failures.Add($"{ch.Title} => {username}:{result.Error}");
}
}
catch (Exception ex2)
{
failed++;
failures.Add($"{ch.Title} => {username}:{ex2.Message}");
}
}
catch (Exception ex)
{
failed++;
failures.Add($"{ch.Title} => {username}:{ex.Message}");
}
finally
{
done++;
}
StateHasChanged();
var wait = delayMs;
if (wait < 0) wait = 0;
if (wait > 30000) wait = 30000;
var jitter = _rand.Next(500, 1500);
await Task.Delay(TimeSpan.FromMilliseconds(wait + jitter), _cts.Token);
}
}
var summary = $"完成:{done}/{total}(失败:{failed})";
Snackbar.Add(summary, failed == 0 ? Severity.Success : Severity.Warning);
if (failures.Count > 0)
{
var details = string.Join(Environment.NewLine, failures.Take(80));
await DialogService.ShowMessageBox("失败明细(前 80 条)", details, "关闭");
}
MudDialog.Close(DialogResult.Ok(new { Done = done, Failed = failed }));
}
catch (OperationCanceledException)
{
Snackbar.Add("已停止执行", Severity.Info);
}
catch (Exception ex)
{
Snackbar.Add($"执行异常:{ex.Message}", Severity.Error);
}
finally
{
running = false;
currentHint = null;
_cts?.Dispose();
_cts = null;
StateHasChanged();
}
}
private (int? ExecutorId, string? Reason) ResolveExecutorAccountId(BotChannel channel)
{
if (!channelAdmins.TryGetValue(channel.TelegramId, out var admins) || admins.Count == 0)
return (null, "无法获取频道管理员列表(请确认 Bot 已加入且为管理员)");
if (selectedAccountId > 0)
{
var selected = eligibleAccounts.FirstOrDefault(x => x.Id == selectedAccountId);
if (selected == null || selected.UserId <= 0)
return (null, "所选执行账号无效");
var admin = admins.FirstOrDefault(x => x.UserId == selected.UserId);
if (admin == null)
return (null, "所选执行账号不是该频道管理员");
if (!admin.IsCreator && !admin.CanInviteUsers)
return (null, "所选执行账号缺少“邀请用户”权限");
return (selected.Id, null);
}
var creator = admins.FirstOrDefault(x => x.IsCreator);
if (creator != null && accountsByUserId.TryGetValue(creator.UserId, out var creatorAcc))
return (creatorAcc.Id, null);
foreach (var a in admins)
{
if (!a.IsCreator && !a.CanInviteUsers)
continue;
if (accountsByUserId.TryGetValue(a.UserId, out var acc))
return (acc.Id, null);
}
return (null, "无可用执行账号(需要该频道管理员且拥有“邀请用户”权限,并且在系统中存在)");
}
private void CancelOrClose()
{
if (running)
{
_cts?.Cancel();
return;
}
MudDialog.Close();
}
private static List<string> ParseUsernames(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))
.Select(x => x.StartsWith("@", StringComparison.Ordinal) ? x.Substring(1) : x)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
}
private static string FormatAccountLabel(Account account)
{
var nickname = string.IsNullOrWhiteSpace(account.Nickname) ? null : account.Nickname.Trim();
var username = string.IsNullOrWhiteSpace(account.Username) ? null : account.Username.Trim();
var namePart = nickname ?? account.Phone;
if (!string.IsNullOrWhiteSpace(username))
return $"{namePart} (@{username})";
return namePart;
}
}