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)