diff --git a/src/TelegramPanel.Core/BatchTasks/BatchTaskTypes.cs b/src/TelegramPanel.Core/BatchTasks/BatchTaskTypes.cs index e65953a..14cd765 100644 --- a/src/TelegramPanel.Core/BatchTasks/BatchTaskTypes.cs +++ b/src/TelegramPanel.Core/BatchTasks/BatchTaskTypes.cs @@ -13,6 +13,7 @@ public static class BatchTaskTypes // User 任务(新增) public const string UserJoinSubscribe = "user_join_subscribe"; + public const string UserChatActive = "user_chat_active"; // External API(记录到任务中心) public const string ExternalApiKick = "external_api_kick"; diff --git a/src/TelegramPanel.Core/Services/Telegram/AccountTelegramToolsService.cs b/src/TelegramPanel.Core/Services/Telegram/AccountTelegramToolsService.cs index b4befe9..05a0445 100644 --- a/src/TelegramPanel.Core/Services/Telegram/AccountTelegramToolsService.cs +++ b/src/TelegramPanel.Core/Services/Telegram/AccountTelegramToolsService.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; +using System.Globalization; using System.Net.Mail; using SixLabors.ImageSharp; using SixLabors.ImageSharp.Formats.Jpeg; @@ -922,6 +923,177 @@ public class AccountTelegramToolsService } } + public sealed record ResolvedChatTarget(InputPeer Peer, string Title, string CanonicalId); + + /// + /// 解析群组/频道目标,支持: + /// - 用户名/链接:@username、username、https://t.me/xxx、t.me/xxx、tg://join?invite=hash + /// - 频道/群组 ID:123456、-123456、-1001234567890 + /// + public async Task<(bool Success, string? Error, ResolvedChatTarget? Target)> ResolveChatTargetAsync( + int accountId, + string target, + CancellationToken cancellationToken = default) + { + try + { + var raw = (target ?? string.Empty).Trim(); + if (raw.Length == 0) + return (false, "目标为空", null); + + var client = await GetOrCreateConnectedClientAsync(accountId, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + + if (TryParseChatIdCandidate(raw, out var normalizedId)) + { + var resolvedById = await TryResolveChatByIdFromDialogsAsync(client, normalizedId, cancellationToken); + if (resolvedById != null) + return (true, null, resolvedById); + + return (false, $"未找到 chatId={raw} 对应的群组/频道(请确认该账号已加入目标)", null); + } + + var url = NormalizeTelegramJoinUrl(raw); + var chat = await client.AnalyzeInviteLink(url, join: false); + cancellationToken.ThrowIfCancellationRequested(); + + var peer = chat switch + { + TL.Channel c => c.ToInputPeer(), + TL.Chat c => c.ToInputPeer(), + _ => null + }; + + if (peer == null) + return (false, "无法解析目标群组/频道", null); + + return chat switch + { + TL.Channel channel => (true, null, new ResolvedChatTarget(peer, NormalizeChatTitle(channel.title, channel.id.ToString(CultureInfo.InvariantCulture)), BuildChannelBotApiChatId(channel.id).ToString(CultureInfo.InvariantCulture))), + TL.Chat basic => (true, null, new ResolvedChatTarget(peer, NormalizeChatTitle(basic.title, basic.id.ToString(CultureInfo.InvariantCulture)), basic.id.ToString(CultureInfo.InvariantCulture))), + _ => (true, null, new ResolvedChatTarget(peer, raw, raw)) + }; + } + catch (Exception ex) + { + var (summary, details) = MapTelegramException(ex); + var msg = string.IsNullOrWhiteSpace(details) ? summary : $"{summary}:{details}"; + return (false, msg, null); + } + } + + /// + /// 向已解析的群组/频道目标发送文本消息。 + /// + public async Task<(bool Success, string? Error)> SendMessageToResolvedChatAsync( + int accountId, + ResolvedChatTarget target, + string message, + CancellationToken cancellationToken = default) + { + try + { + var text = (message ?? string.Empty).Trim(); + if (text.Length == 0) + return (false, "消息内容为空"); + + var client = await GetOrCreateConnectedClientAsync(accountId, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + + _ = await client.SendMessageAsync(target.Peer, text); + return (true, null); + } + catch (Exception ex) + { + var (summary, details) = MapTelegramException(ex); + var msg = string.IsNullOrWhiteSpace(details) ? summary : $"{summary}:{details}"; + return (false, msg); + } + } + + private async Task TryResolveChatByIdFromDialogsAsync( + Client client, + long normalizedId, + CancellationToken cancellationToken) + { + var dialogs = await client.Messages_GetAllDialogs(); + cancellationToken.ThrowIfCancellationRequested(); + + foreach (var chat in dialogs.chats.Values) + { + switch (chat) + { + case TL.Channel channel when channel.IsActive: + { + var rawId = channel.id; + var botApiId = BuildChannelBotApiChatId(rawId); + if (normalizedId != rawId && normalizedId != botApiId) + continue; + + return new ResolvedChatTarget( + channel.ToInputPeer(), + NormalizeChatTitle(channel.title, rawId.ToString(CultureInfo.InvariantCulture)), + botApiId.ToString(CultureInfo.InvariantCulture)); + } + case TL.Chat basic when basic.IsActive: + { + var rawId = basic.id; + var negativeId = -rawId; + if (normalizedId != rawId && normalizedId != negativeId) + continue; + + return new ResolvedChatTarget( + basic.ToInputPeer(), + NormalizeChatTitle(basic.title, rawId.ToString(CultureInfo.InvariantCulture)), + rawId.ToString(CultureInfo.InvariantCulture)); + } + } + } + + return null; + } + + private static bool TryParseChatIdCandidate(string raw, out long normalizedId) + { + normalizedId = 0; + var s = (raw ?? string.Empty).Trim(); + if (s.Length == 0) + return false; + + if (s.StartsWith("+", StringComparison.Ordinal)) + return false; + + if (!long.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)) + return false; + + if (parsed < 0 && s.StartsWith("-100", StringComparison.Ordinal)) + { + var suffix = s[4..]; + if (suffix.Length > 0 && long.TryParse(suffix, NumberStyles.Integer, CultureInfo.InvariantCulture, out var channelId) && channelId > 0) + { + normalizedId = parsed; + return true; + } + } + + normalizedId = parsed; + return true; + } + + private static long BuildChannelBotApiChatId(long channelId) + { + var text = "-100" + channelId.ToString(CultureInfo.InvariantCulture); + if (long.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)) + return parsed; + return channelId; + } + + private static string NormalizeChatTitle(string? title, string fallback) + { + var text = (title ?? string.Empty).Trim(); + return text.Length == 0 ? fallback : text; + } + private static string NormalizeTelegramJoinUrl(string input) { var s = (input ?? string.Empty).Trim(); diff --git a/src/TelegramPanel.Web/Components/Dialogs/UserChatActiveTaskEditor.razor b/src/TelegramPanel.Web/Components/Dialogs/UserChatActiveTaskEditor.razor new file mode 100644 index 0000000..7602510 --- /dev/null +++ b/src/TelegramPanel.Web/Components/Dialogs/UserChatActiveTaskEditor.razor @@ -0,0 +1,236 @@ +@namespace TelegramPanel.Web.Components.Dialogs +@inject AccountCategoryManagementService CategoryManagement +@using System.Text.Json +@using TelegramPanel.Data.Entities +@using TelegramPanel.Modules +@using TelegramPanel.Web.Services +@using Microsoft.AspNetCore.Components + + + + 持续活跃任务会在后台循环执行并按间隔发送内容。MaxMessages=0 表示持续运行,直到你在任务中心手动取消。 + + + @if (loading) + { + + 正在加载账号分类... + } + else + { + + @foreach (var c in categories) + { + @c.Name + } + + + + + + + + + + + + + + + + + + + + + + 随机 + 队列循环 + + + + + 随机 + 队列循环 + + + + + 随机 + 队列循环 + + + + } + + +@code { + [Parameter] public ModuleTaskDraft Draft { get; set; } + [Parameter] public EventCallback DraftChanged { get; set; } + + private List categories = new(); + private bool loading = true; + + private int selectedCategoryId; + private string targetsText = ""; + private string dictionaryText = ""; + private int delayMinMs = 15000; + private int delayMaxMs = 45000; + private int maxMessages = 0; + + private string accountMode = UserChatActiveTaskModes.Random; + private string targetMode = UserChatActiveTaskModes.Queue; + private string messageMode = UserChatActiveTaskModes.Random; + + protected override async Task OnInitializedAsync() + { + try + { + categories = (await CategoryManagement.GetAllCategoriesAsync()) + .Where(x => !x.ExcludeFromOperations) + .OrderBy(x => x.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (selectedCategoryId <= 0) + selectedCategoryId = categories.FirstOrDefault()?.Id ?? 0; + } + finally + { + loading = false; + await PushDraftAsync(); + } + } + + private async Task OnInputsChanged() + { + await PushDraftAsync(); + } + + private async Task PushDraftAsync() + { + var targets = ParseTargetLines(targetsText); + var dictionary = ParseMessageLines(dictionaryText); + + if (loading) + { + await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "账号分类加载中")); + return; + } + + if (categories.Count == 0) + { + await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "没有可用账号分类(或都被标记为排除操作)")); + return; + } + + if (selectedCategoryId <= 0 || categories.All(x => x.Id != selectedCategoryId)) + { + await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "请选择执行账号分类")); + return; + } + + if (targets.Count == 0) + { + await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "请至少填写一个目标群组/频道")); + return; + } + + if (dictionary.Count == 0) + { + await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "请至少填写一条词典消息")); + return; + } + + if (delayMinMs < 0 || delayMaxMs < 0) + { + await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "间隔不能为负数")); + return; + } + + if (delayMaxMs < delayMinMs) + { + await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "最大间隔不能小于最小间隔")); + return; + } + + if (maxMessages < 0) + { + await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "最多发送条数不能小于 0")); + return; + } + + if (!IsValidMode(accountMode) || !IsValidMode(targetMode) || !IsValidMode(messageMode)) + { + await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "模式参数无效")); + return; + } + + var category = categories.First(x => x.Id == selectedCategoryId); + var cfg = new UserChatActiveTaskConfig + { + CategoryId = selectedCategoryId, + CategoryName = category.Name, + Targets = targets, + Dictionary = dictionary, + DelayMinMs = delayMinMs, + DelayMaxMs = delayMaxMs, + MaxMessages = maxMessages, + AccountMode = accountMode, + TargetMode = targetMode, + MessageMode = messageMode + }; + + var total = maxMessages > 0 ? maxMessages : 0; + var json = JsonSerializer.Serialize(cfg, new JsonSerializerOptions { WriteIndented = true }); + await DraftChanged.InvokeAsync(new ModuleTaskDraft(total, json, true, null)); + } + + private static bool IsValidMode(string? value) + { + return string.Equals(value, UserChatActiveTaskModes.Random, StringComparison.OrdinalIgnoreCase) + || string.Equals(value, UserChatActiveTaskModes.Queue, StringComparison.OrdinalIgnoreCase); + } + + private static List ParseTargetLines(string? text) + { + if (string.IsNullOrWhiteSpace(text)) + return new List(); + + return text + .Split(new[] { "\r\n", "\n", "\r" }, StringSplitOptions.RemoveEmptyEntries) + .Select(x => (x ?? string.Empty).Trim()) + .Where(x => x.Length > 0) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + private static List ParseMessageLines(string? text) + { + if (string.IsNullOrWhiteSpace(text)) + return new List(); + + return text + .Split(new[] { "\r\n", "\n", "\r" }, StringSplitOptions.RemoveEmptyEntries) + .Select(x => (x ?? string.Empty).Trim()) + .Where(x => x.Length > 0) + .ToList(); + } +} diff --git a/src/TelegramPanel.Web/Components/Pages/Tasks.razor b/src/TelegramPanel.Web/Components/Pages/Tasks.razor index a69dc8d..3894832 100644 --- a/src/TelegramPanel.Web/Components/Pages/Tasks.razor +++ b/src/TelegramPanel.Web/Components/Pages/Tasks.razor @@ -202,6 +202,7 @@ // fallback:兼容历史任务 return taskType switch { + BatchTaskTypes.UserChatActive => "user", BatchTaskTypes.BotChannelSetAdminsByAccount => "bot", BatchTaskTypes.BotSetAdmins => "bot", _ => "other" @@ -247,6 +248,7 @@ return type switch { + BatchTaskTypes.UserChatActive => "账号持续活跃(群组/频道)", BatchTaskTypes.BotChannelSetAdminsByAccount => "Bot频道批量设置管理员(账号执行)", BatchTaskTypes.BotSetAdmins => "Bot频道批量设置管理员(机器人执行)", _ => type @@ -307,6 +309,8 @@ var failureLines = ExtractBotAdminFailureLines(task); if (failureLines.Count > 0) details += $"\n失败频道:\n{string.Join('\n', failureLines)}"; + else if (IsUserChatActiveTask(task.TaskType) && TryBuildUserChatActiveDetails(task, out var activeDetails)) + details += activeDetails; else if (!IsBotAdminTask(task.TaskType) && !string.IsNullOrEmpty(task.Config)) details += $"\n配置信息:\n{task.Config}"; @@ -319,6 +323,9 @@ private static bool IsBotAdminTask(string taskType) => taskType is BatchTaskTypes.BotChannelSetAdminsByAccount or BatchTaskTypes.BotSetAdmins; + private static bool IsUserChatActiveTask(string taskType) => + taskType is BatchTaskTypes.UserChatActive; + private static List ExtractBotAdminFailureLines(BatchTask task) { var lines = new List(); @@ -377,6 +384,88 @@ return lines; } + private static bool TryBuildUserChatActiveDetails(BatchTask task, out string details) + { + details = string.Empty; + if (!IsUserChatActiveTask(task.TaskType) || string.IsNullOrWhiteSpace(task.Config)) + return false; + + try + { + using var doc = JsonDocument.Parse(task.Config); + var root = doc.RootElement; + + var categoryName = root.TryGetProperty("category_name", out var categoryNameEl) ? (categoryNameEl.GetString() ?? "").Trim() : ""; + var categoryId = root.TryGetProperty("category_id", out var categoryIdEl) && categoryIdEl.TryGetInt32(out var cid) ? cid : 0; + var maxMessages = root.TryGetProperty("max_messages", out var maxMessagesEl) && maxMessagesEl.TryGetInt32(out var mm) ? mm : 0; + var accountMode = root.TryGetProperty("account_mode", out var accountModeEl) ? (accountModeEl.GetString() ?? "").Trim() : ""; + var targetMode = root.TryGetProperty("target_mode", out var targetModeEl) ? (targetModeEl.GetString() ?? "").Trim() : ""; + var messageMode = root.TryGetProperty("message_mode", out var messageModeEl) ? (messageModeEl.GetString() ?? "").Trim() : ""; + var delayMin = root.TryGetProperty("delay_min_ms", out var delayMinEl) && delayMinEl.TryGetInt32(out var dmin) ? dmin : 0; + var delayMax = root.TryGetProperty("delay_max_ms", out var delayMaxEl) && delayMaxEl.TryGetInt32(out var dmax) ? dmax : 0; + var targetCount = root.TryGetProperty("targets", out var targetsEl) && targetsEl.ValueKind == JsonValueKind.Array + ? targetsEl.GetArrayLength() + : 0; + var dictCount = root.TryGetProperty("dictionary", out var dictEl) && dictEl.ValueKind == JsonValueKind.Array + ? dictEl.GetArrayLength() + : 0; + + var lines = new List(); + if (root.TryGetProperty("recent_failures", out var failuresEl) && failuresEl.ValueKind == JsonValueKind.Array) + { + foreach (var item in failuresEl.EnumerateArray()) + { + if (item.ValueKind != JsonValueKind.Object) + continue; + + var account = item.TryGetProperty("account", out var accountEl) ? (accountEl.GetString() ?? "").Trim() : ""; + var target = item.TryGetProperty("target", out var targetEl) ? (targetEl.GetString() ?? "").Trim() : ""; + var reason = item.TryGetProperty("reason", out var reasonEl) ? (reasonEl.GetString() ?? "").Trim() : ""; + var time = item.TryGetProperty("time_utc", out var timeEl) ? (timeEl.GetString() ?? "").Trim() : ""; + + var line = $"{(account.Length == 0 ? "-" : account)} -> {(target.Length == 0 ? "-" : target)}:{(reason.Length == 0 ? "失败" : reason)}"; + if (time.Length > 0) + line = $"{line}({time})"; + lines.Add(line); + } + } + + var categoryText = categoryName.Length > 0 ? $"{categoryName} (#{categoryId})" : $"#{categoryId}"; + details += "\n配置摘要:\n"; + details += $"分类: {categoryText}\n"; + details += $"目标数: {targetCount}\n"; + details += $"词典数: {dictCount}\n"; + details += $"账号模式: {NormalizeTaskModeDisplay(accountMode)}\n"; + details += $"目标模式: {NormalizeTaskModeDisplay(targetMode)}\n"; + details += $"词典模式: {NormalizeTaskModeDisplay(messageMode)}\n"; + details += $"间隔: {delayMin} ~ {delayMax} ms\n"; + details += $"最多发送: {(maxMessages <= 0 ? "持续运行(直到取消)" : maxMessages.ToString())}\n"; + + if (lines.Count > 0) + { + details += "\n最近失败:\n"; + details += string.Join('\n', lines.TakeLast(20)); + } + + return true; + } + catch + { + return false; + } + } + + private static string NormalizeTaskModeDisplay(string? mode) + { + var text = (mode ?? "").Trim(); + return text switch + { + "random" => "随机", + "queue" => "队列循环", + _ => string.IsNullOrWhiteSpace(text) ? "-" : text + }; + } + private async Task CancelTask(int id) { bool? result = await DialogService.ShowMessageBox( diff --git a/src/TelegramPanel.Web/Modules/BuiltIn/TaskCatalogModule.cs b/src/TelegramPanel.Web/Modules/BuiltIn/TaskCatalogModule.cs index ecf4222..f8cbc4a 100644 --- a/src/TelegramPanel.Web/Modules/BuiltIn/TaskCatalogModule.cs +++ b/src/TelegramPanel.Web/Modules/BuiltIn/TaskCatalogModule.cs @@ -32,6 +32,17 @@ public sealed class TaskCatalogModule : ITelegramPanelModule, IModuleTaskProvide public IEnumerable GetTasks(ModuleHostContext context) { + yield return new ModuleTaskDefinition + { + Category = "user", + TaskType = BatchTaskTypes.UserChatActive, + DisplayName = "账号持续活跃(群组/频道)", + Description = "按账号分类持续向指定目标发送词典内容,支持间隔抖动、随机/队列循环。", + Icon = Icons.Material.Filled.Chat, + EditorComponentType = typeof(TelegramPanel.Web.Components.Dialogs.UserChatActiveTaskEditor).AssemblyQualifiedName ?? "", + Order = 120 + }; + yield return new ModuleTaskDefinition { Category = "system", diff --git a/src/TelegramPanel.Web/Program.cs b/src/TelegramPanel.Web/Program.cs index 8d5553c..b71ba68 100644 --- a/src/TelegramPanel.Web/Program.cs +++ b/src/TelegramPanel.Web/Program.cs @@ -417,6 +417,7 @@ builder.Services.Configure(builder.Configuration.GetSectio builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); diff --git a/src/TelegramPanel.Web/Services/UserChatActiveTaskHandler.cs b/src/TelegramPanel.Web/Services/UserChatActiveTaskHandler.cs new file mode 100644 index 0000000..bfd1db3 --- /dev/null +++ b/src/TelegramPanel.Web/Services/UserChatActiveTaskHandler.cs @@ -0,0 +1,346 @@ +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using TelegramPanel.Core.BatchTasks; +using TelegramPanel.Core.Services; +using TelegramPanel.Core.Services.Telegram; +using TelegramPanel.Data.Entities; +using TelegramPanel.Modules; + +namespace TelegramPanel.Web.Services; + +public sealed class UserChatActiveTaskHandler : IModuleTaskHandler +{ + private const int MaxFailureLines = 100; + + public string TaskType => BatchTaskTypes.UserChatActive; + + public async Task ExecuteAsync(IModuleTaskExecutionHost host, CancellationToken cancellationToken) + { + var logger = host.Services.GetRequiredService>(); + var taskManagement = host.Services.GetRequiredService(); + var accountManagement = host.Services.GetRequiredService(); + var accountTools = host.Services.GetRequiredService(); + + var config = DeserializeConfig(host.Config); + ValidateAndNormalizeConfig(config); + + var allAccounts = (await accountManagement.GetAllAccountsAsync()) + .Where(x => x.IsActive && x.UserId > 0 && x.Category?.ExcludeFromOperations != true) + .Where(x => x.CategoryId == config.CategoryId) + .OrderBy(x => x.Id) + .ToList(); + + if (allAccounts.Count == 0) + throw new InvalidOperationException("所选分类下没有可用执行账号"); + + var accountSlots = new List(); + foreach (var account in allAccounts) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!await host.IsStillRunningAsync(cancellationToken)) + { + config.Canceled = true; + await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(config)); + return; + } + + var slot = new AccountSlot(account); + foreach (var rawTarget in config.Targets) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!await host.IsStillRunningAsync(cancellationToken)) + { + config.Canceled = true; + await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(config)); + return; + } + + var resolved = await accountTools.ResolveChatTargetAsync(account.Id, rawTarget, cancellationToken); + if (resolved.Success && resolved.Target != null) + { + slot.Targets.Add(new TargetSlot(rawTarget, resolved.Target)); + continue; + } + + AddFailure(config, account, rawTarget, NormalizeReason(resolved.Error)); + } + + if (slot.Targets.Count > 0) + accountSlots.Add(slot); + } + + if (accountSlots.Count == 0) + { + config.Error = "没有可用的账号-目标组合(请确认账号已加入目标群组/频道)"; + await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(config)); + throw new InvalidOperationException(config.Error); + } + + await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(config)); + + var completed = 0; + var failed = 0; + var accountQueueIndex = 0; + var messageQueueIndex = 0; + var targetQueueIndexByAccountId = new Dictionary(); + var lastProgressPersistAt = DateTime.UtcNow; + + try + { + while (!cancellationToken.IsCancellationRequested) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (!await host.IsStillRunningAsync(cancellationToken)) + { + config.Canceled = true; + break; + } + + if (config.MaxMessages > 0 && completed >= config.MaxMessages) + break; + + var accountIdx = SelectIndex(config.AccountMode, accountSlots.Count, ref accountQueueIndex); + var accountSlot = accountSlots[accountIdx]; + + if (!targetQueueIndexByAccountId.ContainsKey(accountSlot.Account.Id)) + targetQueueIndexByAccountId[accountSlot.Account.Id] = 0; + + var targetQueueIndex = targetQueueIndexByAccountId[accountSlot.Account.Id]; + var targetIdx = SelectIndex(config.TargetMode, accountSlot.Targets.Count, ref targetQueueIndex); + targetQueueIndexByAccountId[accountSlot.Account.Id] = targetQueueIndex; + var targetSlot = accountSlot.Targets[targetIdx]; + + var messageIdx = SelectIndex(config.MessageMode, config.Dictionary.Count, ref messageQueueIndex); + var text = config.Dictionary[messageIdx]; + + var send = await accountTools.SendMessageToResolvedChatAsync( + accountSlot.Account.Id, + targetSlot.Resolved, + text, + cancellationToken); + + completed++; + var hadFailureThisRound = false; + + if (!send.Success) + { + failed++; + hadFailureThisRound = true; + AddFailure(config, accountSlot.Account, targetSlot.RawTarget, NormalizeReason(send.Error)); + + if (LooksLikePeerInvalid(send.Error)) + { + var refresh = await accountTools.ResolveChatTargetAsync( + accountSlot.Account.Id, + targetSlot.RawTarget, + cancellationToken); + + if (refresh.Success && refresh.Target != null) + targetSlot.Resolved = refresh.Target; + } + + await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(config)); + } + + if (ShouldPersistProgress(completed, hadFailureThisRound, lastProgressPersistAt)) + { + await host.UpdateProgressAsync(completed, failed, cancellationToken); + lastProgressPersistAt = DateTime.UtcNow; + } + + if (config.MaxMessages > 0 && completed >= config.MaxMessages) + break; + + var delayMs = NextDelayMilliseconds(config.DelayMinMs, config.DelayMaxMs); + if (delayMs > 0) + await Task.Delay(delayMs, cancellationToken); + } + } + catch (Exception ex) + { + logger.LogWarning(ex, "UserChatActive task failed (taskId={TaskId})", host.TaskId); + config.Error = ex.Message; + await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(config)); + throw; + } + + await host.UpdateProgressAsync(completed, failed, cancellationToken); + if (config.Canceled) + { + await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(config)); + return; + } + + config.Error = null; + await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(config)); + } + + private static UserChatActiveTaskConfig DeserializeConfig(string? rawConfig) + { + var raw = (rawConfig ?? string.Empty).Trim(); + if (raw.Length == 0) + throw new InvalidOperationException("任务缺少 Config"); + + try + { + return JsonSerializer.Deserialize(raw) + ?? throw new InvalidOperationException("任务 Config JSON 为空"); + } + catch (Exception ex) + { + throw new InvalidOperationException($"任务 Config JSON 无效:{ex.Message}"); + } + } + + private static void ValidateAndNormalizeConfig(UserChatActiveTaskConfig config) + { + if (config.CategoryId <= 0) + throw new InvalidOperationException("任务缺少账号分类"); + + config.Targets = config.Targets + .Select(x => (x ?? string.Empty).Trim()) + .Where(x => x.Length > 0) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + config.Dictionary = config.Dictionary + .Select(x => (x ?? string.Empty).Trim()) + .Where(x => x.Length > 0) + .ToList(); + + if (config.Targets.Count == 0) + throw new InvalidOperationException("任务缺少目标群组/频道"); + + if (config.Dictionary.Count == 0) + throw new InvalidOperationException("任务缺少词典消息"); + + if (config.DelayMinMs < 0) config.DelayMinMs = 0; + if (config.DelayMaxMs < 0) config.DelayMaxMs = 0; + if (config.DelayMinMs > 600000) config.DelayMinMs = 600000; + if (config.DelayMaxMs > 600000) config.DelayMaxMs = 600000; + if (config.DelayMaxMs < config.DelayMinMs) config.DelayMaxMs = config.DelayMinMs; + + if (config.MaxMessages < 0) config.MaxMessages = 0; + + config.AccountMode = NormalizeMode(config.AccountMode); + config.TargetMode = NormalizeMode(config.TargetMode); + config.MessageMode = NormalizeMode(config.MessageMode); + + config.RecentFailures ??= new List(); + } + + private static string NormalizeMode(string? mode) + { + return string.Equals((mode ?? string.Empty).Trim(), UserChatActiveTaskModes.Queue, StringComparison.OrdinalIgnoreCase) + ? UserChatActiveTaskModes.Queue + : UserChatActiveTaskModes.Random; + } + + private static int SelectIndex(string mode, int count, ref int queueIndex) + { + if (count <= 1) + return 0; + + if (string.Equals(mode, UserChatActiveTaskModes.Queue, StringComparison.OrdinalIgnoreCase)) + { + var idx = queueIndex % count; + queueIndex = (queueIndex + 1) % int.MaxValue; + return idx; + } + + return Random.Shared.Next(0, count); + } + + private static int NextDelayMilliseconds(int minMs, int maxMs) + { + if (minMs <= 0 && maxMs <= 0) + return 0; + + if (maxMs <= minMs) + return minMs; + + return Random.Shared.Next(minMs, maxMs + 1); + } + + private static bool ShouldPersistProgress(int completed, bool hadFailureThisRound, DateTime lastPersistAt) + { + if (completed <= 1) + return true; + + if (hadFailureThisRound) + return true; + + if (completed % 5 == 0) + return true; + + return (DateTime.UtcNow - lastPersistAt) >= TimeSpan.FromSeconds(10); + } + + private static void AddFailure(UserChatActiveTaskConfig config, Account account, string rawTarget, string reason) + { + config.RecentFailures ??= new List(); + config.RecentFailures.Add(new UserChatActiveTaskRuntimeFailure + { + TimeUtc = DateTime.UtcNow, + AccountId = account.Id, + Account = BuildAccountDisplayName(account), + Target = (rawTarget ?? string.Empty).Trim(), + Reason = reason + }); + + if (config.RecentFailures.Count > MaxFailureLines) + config.RecentFailures.RemoveRange(0, config.RecentFailures.Count - MaxFailureLines); + } + + private static string BuildAccountDisplayName(Account account) + { + var nickname = string.IsNullOrWhiteSpace(account.Nickname) ? "" : $" ({account.Nickname.Trim()})"; + return $"{account.DisplayPhone}#{account.Id}{nickname}"; + } + + private static bool LooksLikePeerInvalid(string? error) + { + var text = (error ?? string.Empty).ToUpperInvariant(); + return text.Contains("PEER_ID_INVALID", StringComparison.Ordinal) + || text.Contains("CHAT_ID_INVALID", StringComparison.Ordinal) + || text.Contains("CHANNEL_INVALID", StringComparison.Ordinal) + || text.Contains("USERNAME_INVALID", StringComparison.Ordinal) + || text.Contains("USERNAME_NOT_OCCUPIED", StringComparison.Ordinal); + } + + private static string NormalizeReason(string? reason) + { + var text = (reason ?? string.Empty).Trim(); + return text.Length == 0 ? "失败" : text; + } + + private static string SerializeIndented(UserChatActiveTaskConfig config) + { + return JsonSerializer.Serialize(config, new JsonSerializerOptions { WriteIndented = true }); + } + + private sealed class AccountSlot + { + public AccountSlot(Account account) + { + Account = account; + } + + public Account Account { get; } + public List Targets { get; } = new(); + } + + private sealed class TargetSlot + { + public TargetSlot(string rawTarget, AccountTelegramToolsService.ResolvedChatTarget resolved) + { + RawTarget = rawTarget; + Resolved = resolved; + } + + public string RawTarget { get; } + public AccountTelegramToolsService.ResolvedChatTarget Resolved { get; set; } + } +} diff --git a/src/TelegramPanel.Web/Services/UserChatActiveTaskModels.cs b/src/TelegramPanel.Web/Services/UserChatActiveTaskModels.cs new file mode 100644 index 0000000..c8f5b46 --- /dev/null +++ b/src/TelegramPanel.Web/Services/UserChatActiveTaskModels.cs @@ -0,0 +1,69 @@ +using System.Text.Json.Serialization; + +namespace TelegramPanel.Web.Services; + +public static class UserChatActiveTaskModes +{ + public const string Random = "random"; + public const string Queue = "queue"; +} + +public sealed class UserChatActiveTaskConfig +{ + [JsonPropertyName("category_id")] + public int CategoryId { get; set; } + + [JsonPropertyName("category_name")] + public string? CategoryName { get; set; } + + [JsonPropertyName("targets")] + public List Targets { get; set; } = new(); + + [JsonPropertyName("dictionary")] + public List Dictionary { get; set; } = new(); + + [JsonPropertyName("delay_min_ms")] + public int DelayMinMs { get; set; } = 15000; + + [JsonPropertyName("delay_max_ms")] + public int DelayMaxMs { get; set; } = 45000; + + [JsonPropertyName("account_mode")] + public string AccountMode { get; set; } = UserChatActiveTaskModes.Random; + + [JsonPropertyName("message_mode")] + public string MessageMode { get; set; } = UserChatActiveTaskModes.Random; + + [JsonPropertyName("target_mode")] + public string TargetMode { get; set; } = UserChatActiveTaskModes.Queue; + + [JsonPropertyName("max_messages")] + public int MaxMessages { get; set; } + + [JsonPropertyName("canceled")] + public bool Canceled { get; set; } + + [JsonPropertyName("error")] + public string? Error { get; set; } + + [JsonPropertyName("recent_failures")] + public List RecentFailures { get; set; } = new(); +} + +public sealed class UserChatActiveTaskRuntimeFailure +{ + [JsonPropertyName("time_utc")] + public DateTime TimeUtc { get; set; } + + [JsonPropertyName("account_id")] + public int AccountId { get; set; } + + [JsonPropertyName("account")] + public string Account { get; set; } = ""; + + [JsonPropertyName("target")] + public string Target { get; set; } = ""; + + [JsonPropertyName("reason")] + public string Reason { get; set; } = ""; +}