diff --git a/src/TelegramPanel.Core/Services/Telegram/AccountTelegramToolsService.cs b/src/TelegramPanel.Core/Services/Telegram/AccountTelegramToolsService.cs index aef7fd7..64da39d 100644 --- a/src/TelegramPanel.Core/Services/Telegram/AccountTelegramToolsService.cs +++ b/src/TelegramPanel.Core/Services/Telegram/AccountTelegramToolsService.cs @@ -830,6 +830,96 @@ public class AccountTelegramToolsService } } + /// + /// 启用外部 Bot(向 Bot 发送 /start,可带参数)。 + /// 支持:@xxxbot、xxxbot、https://t.me/xxxbot、tg://resolve?domain=xxxbot&start=abc + /// + 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); + } + } + + /// + /// 停用外部 Bot(通过拉黑 Bot 实现)。 + /// 支持:@xxxbot、xxxbot、https://t.me/xxxbot、tg://resolve?domain=xxxbot + /// + 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 ParseQueryString(string query) + { + var map = new Dictionary(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; + } + /// /// 更新当前账号头像(静态图片)。 /// diff --git a/src/TelegramPanel.Web/Components/Dialogs/BatchChatMembershipDialog.razor b/src/TelegramPanel.Web/Components/Dialogs/BatchChatMembershipDialog.razor index d5ccec2..6b88fe7 100644 --- a/src/TelegramPanel.Web/Components/Dialogs/BatchChatMembershipDialog.razor +++ b/src/TelegramPanel.Web/Components/Dialogs/BatchChatMembershipDialog.razor @@ -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 @@ @if (IsJoin) { - 将使用所选账号订阅频道/加入群组,并可选批量启用 Bot。 + 将使用所选账号订阅频道/加入群组,并可选批量启用外部 Bot(发送 /start)。 } else { - 将使用所选账号退出群组/取消订阅频道,并可选批量停用 Bot。 + 将使用所选账号退出群组/取消订阅频道,并可选批量停用外部 Bot(拉黑 Bot)。 } - 支持:https://t.me/xxx、t.me/+hash、@@username、username、tg://join?invite=hash + 群组/频道支持:https://t.me/xxx、t.me/+hash、@@username、username、tg://join?invite=hash @@ -33,55 +32,39 @@ - Bot 操作(可选) + 外部 Bot 操作(可选) - @(IsJoin ? "批量启用" : "批量停用") + @(IsJoin ? "批量启用 Bot" : "批量停用 Bot") - @if (bots.Count == 0) - { - 暂无 Bot,已自动跳过 Bot 批量操作。 - } - else - { - + - @if (enableBotOperation) + @if (enableBotOperation) + { + + + @if (IsJoin) { - - 已选 Bot:@selectedBotIds.Count / @bots.Count - 选中推荐 - 全选 - 清空 - - - - - @foreach (var bot in orderedBots) - { - - - - } - - - - @if (IsJoin) - { - - } + + } + else + { + + 说明:停用 Bot 通过“拉黑 Bot”实现;如需恢复,可在 Telegram 内手动解除拉黑。 + } } @@ -102,7 +85,7 @@ @(running ? "停止" : "关闭") - @(IsJoin ? "开始加群/订阅" : "开始退群/退订") + @(IsJoin ? "开始加群/订阅/启用Bot" : "开始退群/退订/停用Bot") @@ -115,12 +98,10 @@ [Parameter] public string Operation { get; set; } = "join"; // join|leave private List accounts = new(); - private List bots = new(); - private List orderedBots = new(); private string linksText = ""; - private string botStartParameters = ""; private bool enableBotOperation; - private HashSet 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()).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(); 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 GetSelectedBots() + private static int ClampDelayMs(int value) { - if (selectedBotIds.Count == 0 || bots.Count == 0) - return new List(); - - 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 ParseLines(string? text)