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; } = "";
+}