feat(accounts): 批量操作支持外部 Bot 启停

- 批量弹窗改为输入外部 Bot 用户名/链接,支持带参数批量 /start

- 批量停用 Bot 改为账号侧拉黑 Bot(可在 Telegram 手动解除)

- 新增 AccountTelegramToolsService.StartExternalBotAsync/StopExternalBotAsync
This commit is contained in:
meoacgx committed 2026-03-03 05:10:14 +08:00
1 parent 1dfb80cdd2
commit 04257e1e86
2 files changed
+280 -149

No files matched your search

@@ -830,6 +830,96 @@ public class AccountTelegramToolsService
}
}
/// <summary>
/// 启用外部 Bot(向 Bot 发送 /start,可带参数)。
/// 支持:@xxxbot、xxxbot、https://t.me/xxxbot、tg://resolve?domain=xxxbot&start=abc
/// </summary>
public async Task<(bool Success, string? Error, string? BotUsername)> StartExternalBotAsync(
int accountId,
string botLinkOrUsername,
string? startParameter = null,
CancellationToken cancellationToken = default)
{
try
{
var (username, startFromLink) = NormalizeTelegramBotUsername(botLinkOrUsername);
var normalizedManualStart = NormalizeBotStartParameter(startParameter);
var finalStart = string.IsNullOrWhiteSpace(normalizedManualStart) ? startFromLink : normalizedManualStart;
if (finalStart.Length > 64)
return (false, "启动参数过长(最多 64 字符)", null);
var client = await GetOrCreateConnectedClientAsync(accountId, cancellationToken);
cancellationToken.ThrowIfCancellationRequested();
var resolved = await client.Contacts_ResolveUsername(username);
var user = resolved.User;
if (user.access_hash == 0)
return (false, "无法获取 Bot access_hash", null);
var inputUser = new InputUser(user.id, user.access_hash);
var randomId = Random.Shared.NextInt64();
await client.Messages_StartBot(
bot: inputUser,
peer: new InputPeerSelf(),
random_id: randomId,
start_param: finalStart);
return (true, null, "@" + username);
}
catch (RpcException ex) when (ex.Code == 400 && string.Equals(ex.Message, "BOT_APP_INVALID", StringComparison.OrdinalIgnoreCase))
{
return (false, "目标不是可启动的 Bot(BOT_APP_INVALID)", null);
}
catch (RpcException ex) when (ex.Code == 400 && string.Equals(ex.Message, "PEER_FLOOD", StringComparison.OrdinalIgnoreCase))
{
return (false, "触发风控(PEER_FLOOD),请降低频率后重试", null);
}
catch (Exception ex)
{
var (summary, details) = MapTelegramException(ex);
var msg = string.IsNullOrWhiteSpace(details) ? summary : $"{summary}:{details}";
return (false, msg, null);
}
}
/// <summary>
/// 停用外部 Bot(通过拉黑 Bot 实现)。
/// 支持:@xxxbot、xxxbot、https://t.me/xxxbot、tg://resolve?domain=xxxbot
/// </summary>
public async Task<(bool Success, string? Error, string? BotUsername)> StopExternalBotAsync(
int accountId,
string botLinkOrUsername,
CancellationToken cancellationToken = default)
{
try
{
var (username, _) = NormalizeTelegramBotUsername(botLinkOrUsername);
var client = await GetOrCreateConnectedClientAsync(accountId, cancellationToken);
cancellationToken.ThrowIfCancellationRequested();
var resolved = await client.Contacts_ResolveUsername(username);
var user = resolved.User;
if (user.access_hash == 0)
return (false, "无法获取 Bot access_hash", null);
await client.Contacts_Block(new InputPeerUser(user.id, user.access_hash));
return (true, null, "@" + username);
}
catch (RpcException ex) when (ex.Code == 400 && string.Equals(ex.Message, "USER_NOT_MUTUAL_CONTACT", StringComparison.OrdinalIgnoreCase))
{
// 某些账号状态下会返回该错误,按“已停用”处理可避免批量任务中断。
return (true, null, 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();
@@ -867,6 +957,112 @@ public class AccountTelegramToolsService
return s;
}
private static (string Username, string StartFromLink) NormalizeTelegramBotUsername(string input)
{
var s = (input ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(s))
throw new ArgumentException("Bot 用户名为空", nameof(input));
string startFromLink = string.Empty;
// tg://resolve?domain=xxxbot&start=abc
if (s.StartsWith("tg://", StringComparison.OrdinalIgnoreCase)
&& Uri.TryCreate(s, UriKind.Absolute, out var tgUri))
{
var query = ParseQueryString(tgUri.Query);
if (query.TryGetValue("domain", out var domain) && !string.IsNullOrWhiteSpace(domain))
s = domain.Trim();
if (query.TryGetValue("start", out var start) && !string.IsNullOrWhiteSpace(start))
startFromLink = NormalizeBotStartParameter(start);
}
// https://t.me/xxxbot?start=abc 或 t.me/xxxbot?start=abc
if (s.StartsWith("http://", StringComparison.OrdinalIgnoreCase)
|| s.StartsWith("https://", StringComparison.OrdinalIgnoreCase)
|| s.StartsWith("t.me/", StringComparison.OrdinalIgnoreCase)
|| s.StartsWith("telegram.me/", StringComparison.OrdinalIgnoreCase))
{
var url = s.Contains("://", StringComparison.Ordinal) ? s : "https://" + s;
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
throw new ArgumentException("Bot 链接格式无效", nameof(input));
var path = (uri.AbsolutePath ?? string.Empty).Trim('/');
var firstSeg = path.Split('/', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault() ?? string.Empty;
if (string.IsNullOrWhiteSpace(firstSeg))
throw new ArgumentException("Bot 链接中缺少用户名", nameof(input));
s = firstSeg;
var query = ParseQueryString(uri.Query);
if (query.TryGetValue("start", out var start) && !string.IsNullOrWhiteSpace(start))
startFromLink = NormalizeBotStartParameter(start);
}
s = s.Trim().TrimStart('@');
var slash = s.IndexOf('/');
if (slash >= 0)
s = s[..slash];
if (string.IsNullOrWhiteSpace(s))
throw new ArgumentException("Bot 用户名为空", nameof(input));
if (s.StartsWith("+", StringComparison.Ordinal))
throw new ArgumentException("邀请链接不是 Bot 用户名,请输入 @xxxbot 或 t.me/xxxbot", nameof(input));
if (!System.Text.RegularExpressions.Regex.IsMatch(s, "^[A-Za-z0-9_]{5,64}$"))
throw new ArgumentException("Bot 用户名格式无效", nameof(input));
if (!s.EndsWith("bot", StringComparison.OrdinalIgnoreCase))
throw new ArgumentException("目标看起来不是 Bot 用户名(需以 bot 结尾)", nameof(input));
return (s, startFromLink);
}
private static string NormalizeBotStartParameter(string? input)
{
var s = (input ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(s))
return string.Empty;
if (s.StartsWith("/start", StringComparison.OrdinalIgnoreCase))
s = s[6..].Trim();
if (s.StartsWith("@", StringComparison.Ordinal))
{
var idx = s.IndexOf(' ');
s = idx > 0 ? s[(idx + 1)..].Trim() : string.Empty;
}
return s;
}
private static Dictionary<string, string> ParseQueryString(string query)
{
var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (string.IsNullOrWhiteSpace(query))
return map;
var raw = query.StartsWith("?", StringComparison.Ordinal) ? query[1..] : query;
foreach (var part in raw.Split('&', StringSplitOptions.RemoveEmptyEntries))
{
var idx = part.IndexOf('=');
if (idx < 0)
{
var kOnly = Uri.UnescapeDataString(part).Trim();
if (!string.IsNullOrWhiteSpace(kOnly))
map[kOnly] = string.Empty;
continue;
}
var key = Uri.UnescapeDataString(part[..idx]).Trim();
var val = Uri.UnescapeDataString(part[(idx + 1)..]).Trim();
if (!string.IsNullOrWhiteSpace(key))
map[key] = val;
}
return map;
}
/// <summary>
/// 更新当前账号头像(静态图片)。
/// </summary>
@@ -1,6 +1,5 @@
@inject AccountManagementService AccountManagement
@inject AccountTelegramToolsService AccountTelegramTools
@inject BotManagementService BotManagement
@inject TelegramPanel.Core.Services.AccountRiskService RiskService
@inject ISnackbar Snackbar
@inject IDialogService DialogService
@@ -12,14 +11,14 @@
<MudAlert Severity="Severity.Warning" Variant="Variant.Outlined" Dense="true">
@if (IsJoin)
{
<span>将使用所选账号订阅频道/加入群组,并可选批量启用 Bot。</span>
<span>将使用所选账号订阅频道/加入群组,并可选批量启用外部 Bot(发送 /start)。</span>
}
else
{
<span>将使用所选账号退出群组/取消订阅频道,并可选批量停用 Bot。</span>
<span>将使用所选账号退出群组/取消订阅频道,并可选批量停用外部 Bot(拉黑 Bot)。</span>
}
<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>
群组/频道支持:<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>
@@ -33,55 +32,39 @@
<MudDivider />
<MudStack Row="true" Spacing="1" AlignItems="AlignItems.Center">
<MudText Typo="Typo.subtitle2">Bot 操作(可选)</MudText>
<MudText Typo="Typo.subtitle2">外部 Bot 操作(可选)</MudText>
<MudChip T="string" Size="Size.Small" Color="@(IsJoin ? Color.Success : Color.Warning)">
@(IsJoin ? "批量启用" : "批量停用")
@(IsJoin ? "批量启用 Bot" : "批量停用 Bot")
</MudChip>
</MudStack>
@if (bots.Count == 0)
{
<MudText Typo="Typo.caption" Color="Color.Secondary">暂无 Bot,已自动跳过 Bot 批量操作。</MudText>
}
else
{
<MudCheckBox T="bool"
@bind-Value="enableBotOperation"
Label="同时执行 Bot 批量操作"
Disabled="@running" />
<MudCheckBox T="bool"
@bind-Value="enableBotOperation"
Label="同时执行外部 Bot 批量操作"
Disabled="@running" />
@if (enableBotOperation)
@if (enableBotOperation)
{
<MudTextField @bind-Value="botTargetsText"
Label="Bot 用户名/链接列表(换行分隔)"
Variant="Variant.Outlined"
Lines="6"
Disabled="@running"
HelperText="支持用户名 xxxbot 或链接 https://t.me/xxxbot(每行一个)" />
@if (IsJoin)
{
<MudStack Row="true" Spacing="1" AlignItems="AlignItems.Center" Style="flex-wrap: wrap;">
<MudText Typo="Typo.caption">已选 Bot:@selectedBotIds.Count / @bots.Count</MudText>
<MudButton Variant="Variant.Text" Size="Size.Small" Disabled="@running" OnClick="SelectRecommendedBots">选中推荐</MudButton>
<MudButton Variant="Variant.Text" Size="Size.Small" Disabled="@running" OnClick="SelectAllBots">全选</MudButton>
<MudButton Variant="Variant.Text" Size="Size.Small" Disabled="@running" OnClick="ClearSelectedBots">清空</MudButton>
</MudStack>
<MudPaper Outlined="true" Class="pa-2">
<MudGrid>
@foreach (var bot in orderedBots)
{
<MudItem xs="12" sm="6" md="4">
<MudCheckBox T="bool"
Value="@selectedBotIds.Contains(bot.Id)"
ValueChanged="@(v => SetBotSelection(bot.Id, v))"
Disabled="@running"
Label="@BuildBotLabel(bot)" />
</MudItem>
}
</MudGrid>
</MudPaper>
@if (IsJoin)
{
<MudTextField @bind-Value="botStartParameters"
Label="Bot 启动参数(可选)"
Variant="Variant.Outlined"
HelperText="当前版本仅执行启用动作,启动参数暂不生效。"
Disabled="@running" />
}
<MudTextField @bind-Value="botStartParameters"
Label="Bot 启动参数(可选)"
Variant="Variant.Outlined"
Disabled="@running"
HelperText="可只填参数(如 abc),也可填 /start abc;将对所有 Bot 复用同一参数。" />
}
else
{
<MudText Typo="Typo.caption" Color="Color.Secondary">
说明:停用 Bot 通过“拉黑 Bot”实现;如需恢复,可在 Telegram 内手动解除拉黑。
</MudText>
}
}
@@ -102,7 +85,7 @@
@(running ? "停止" : "关闭")
</MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="Submit" Disabled="@(running || accounts.Count == 0)">
@(IsJoin ? "开始加群/订阅" : "开始退群/退订")
@(IsJoin ? "开始加群/订阅/启用Bot" : "开始退群/退订/停用Bot")
</MudButton>
</DialogActions>
</MudDialog>
@@ -115,12 +98,10 @@
[Parameter] public string Operation { get; set; } = "join"; // join|leave
private List<Account> accounts = new();
private List<Bot> bots = new();
private List<Bot> orderedBots = new();
private string linksText = "";
private string botStartParameters = "";
private bool enableBotOperation;
private HashSet<int> selectedBotIds = new();
private string botTargetsText = "";
private string botStartParameters = "";
private int delayMs = 2000;
private bool running;
private int total;
@@ -136,25 +117,11 @@
protected override async Task OnInitializedAsync()
{
var ids = (AccountIds ?? new List<int>()).Where(x => x > 0).Distinct().ToList();
if (ids.Count == 0)
return;
var loadAccountsTask = AccountManagement.GetAllAccountsAsync();
var loadBotsTask = BotManagement.GetAllBotsAsync();
await Task.WhenAll(loadAccountsTask, loadBotsTask);
if (ids.Count > 0)
{
var all = loadAccountsTask.Result.ToList();
accounts = all.Where(a => ids.Contains(a.Id)).ToList();
}
bots = loadBotsTask.Result.ToList();
orderedBots = bots
.OrderBy(b => b.Name, StringComparer.OrdinalIgnoreCase)
.ThenBy(b => b.Id)
.ToList();
SelectRecommendedBots();
var all = (await AccountManagement.GetAllAccountsAsync()).ToList();
accounts = all.Where(a => ids.Contains(a.Id)).ToList();
}
private async Task Submit()
@@ -163,20 +130,20 @@
return;
var links = ParseLines(linksText);
var botTargets = ParseLines(botTargetsText);
var targetAccounts = accounts.ToList();
var runMembershipOps = links.Count > 0;
var selectedBots = GetSelectedBots();
var runBotOps = enableBotOperation && selectedBots.Count > 0;
var runBotOps = enableBotOperation && botTargets.Count > 0;
if (enableBotOperation && bots.Count > 0 && selectedBots.Count == 0)
if (enableBotOperation && botTargets.Count == 0)
{
Snackbar.Add("已启用 Bot 批量操作,请至少选择 1 个 Bot", Severity.Warning);
Snackbar.Add("已启用 Bot 批量操作,请至少输入一个 Bot 用户名/链接", Severity.Warning);
return;
}
if (!runMembershipOps && !runBotOps)
{
Snackbar.Add("请至少输入一个频道/群链接,或选择 Bot 批量操作", Severity.Warning);
Snackbar.Add("请至少输入一个频道/群链接,或启用 Bot 批量操作", Severity.Warning);
return;
}
@@ -214,17 +181,17 @@
}
var membershipTotal = runMembershipOps ? targetAccounts.Count * links.Count : 0;
var botTotal = runBotOps ? selectedBots.Count : 0;
var botTotal = runBotOps ? targetAccounts.Count * botTargets.Count : 0;
var confirmLines = new List<string>();
if (membershipTotal > 0)
confirmLines.Add($"{(IsJoin ? "加群/订阅" : "退群/退订")}:{targetAccounts.Count} 个账号 × {links.Count} 个目标(共 {membershipTotal} 次操作)");
if (botTotal > 0)
confirmLines.Add($"{(IsJoin ? "启用" : "停用")} Bot:{selectedBots.Count} 个");
confirmLines.Add($"{(IsJoin ? "启用" : "停用")}外部 Bot:{targetAccounts.Count} 个账号 × {botTargets.Count} 个 Bot(共 {botTotal} 次操作)");
if (IsJoin && runBotOps && !string.IsNullOrWhiteSpace(botStartParameters))
confirmLines.Add($"启动参数:{botStartParameters.Trim()}(当前版本暂不生效)");
confirmLines.Add($"启动参数:{botStartParameters.Trim()}");
bool? confirm = await DialogService.ShowMessageBox(
"确认执行",
@@ -234,9 +201,6 @@
if (confirm != true)
return;
if (IsJoin && runBotOps && !string.IsNullOrWhiteSpace(botStartParameters))
Snackbar.Add("已填写启动参数,但当前版本仅执行 Bot 启用,参数暂不生效。", Severity.Info);
running = true;
total = membershipTotal + botTotal;
done = 0;
@@ -274,9 +238,7 @@
StateHasChanged();
var wait = delayMs;
if (wait < 0) wait = 0;
if (wait > 10000) wait = 10000;
var wait = ClampDelayMs(delayMs);
var jitter = _rand.Next(0, 350);
await Task.Delay(TimeSpan.FromMilliseconds(wait + jitter), _cts.Token);
}
@@ -285,28 +247,45 @@
if (runBotOps)
{
foreach (var bot in selectedBots)
foreach (var a in targetAccounts)
{
_cts.Token.ThrowIfCancellationRequested();
foreach (var botTarget in botTargets)
{
_cts.Token.ThrowIfCancellationRequested();
currentHint = $"{(IsJoin ? "启用" : "停用")} Bot => {bot.Name}";
StateHasChanged();
currentHint = $"{a.DisplayPhone} => {botTarget}";
StateHasChanged();
(bool Success, string? Error, string? BotUsername) result;
if (IsJoin)
{
result = await AccountTelegramTools.StartExternalBotAsync(
a.Id,
botTarget,
botStartParameters,
_cts.Token);
}
else
{
result = await AccountTelegramTools.StopExternalBotAsync(
a.Id,
botTarget,
_cts.Token);
}
try
{
await BotManagement.SetBotActiveStatusAsync(bot.Id, IsJoin);
}
catch (Exception ex)
{
failed++;
failures.Add($"{(IsJoin ? "启用" : "停用")} Bot {bot.Name} 失败:{ex.Message}");
}
finally
{
done++;
}
if (!result.Success)
{
failed++;
failures.Add($"{a.DisplayPhone} => {botTarget}:{result.Error}");
}
StateHasChanged();
StateHasChanged();
var wait = ClampDelayMs(delayMs);
var jitter = _rand.Next(0, 350);
await Task.Delay(TimeSpan.FromMilliseconds(wait + jitter), _cts.Token);
}
}
}
@@ -350,55 +329,11 @@
MudDialog.Close();
}
private List<Bot> GetSelectedBots()
private static int ClampDelayMs(int value)
{
if (selectedBotIds.Count == 0 || bots.Count == 0)
return new List<Bot>();
return bots
.Where(b => selectedBotIds.Contains(b.Id))
.OrderBy(b => b.Name, StringComparer.OrdinalIgnoreCase)
.ThenBy(b => b.Id)
.ToList();
}
private void SelectRecommendedBots()
{
if (bots.Count == 0)
{
selectedBotIds.Clear();
return;
}
selectedBotIds = bots
.Where(b => IsJoin ? !b.IsActive : b.IsActive)
.Select(b => b.Id)
.ToHashSet();
}
private void SelectAllBots()
{
selectedBotIds = bots.Select(b => b.Id).ToHashSet();
}
private void ClearSelectedBots()
{
selectedBotIds.Clear();
}
private void SetBotSelection(int botId, bool selected)
{
if (selected)
selectedBotIds.Add(botId);
else
selectedBotIds.Remove(botId);
}
private static string BuildBotLabel(Bot bot)
{
var username = string.IsNullOrWhiteSpace(bot.Username) ? "-" : $"@{bot.Username}";
var status = bot.IsActive ? "启用" : "停用";
return $"{bot.Name}({username},当前{status})";
if (value < 0) return 0;
if (value > 10000) return 10000;
return value;
}
private static List<string> ParseLines(string? text)