feat: 任务中心新增持续活跃任务

This commit is contained in:
meoacgx committed 2026-03-06 01:11:00 +08:00
1 parent 4688950051
commit b7e220ae0b
8 files changed
+925

No files matched your search

@@ -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";
@@ -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);
/// <summary>
/// 解析群组/频道目标,支持:
/// - 用户名/链接:@username、username、https://t.me/xxx、t.me/xxx、tg://join?invite=hash
/// - 频道/群组 ID:123456、-123456、-1001234567890
/// </summary>
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);
}
}
/// <summary>
/// 向已解析的群组/频道目标发送文本消息。
/// </summary>
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<ResolvedChatTarget?> 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();
@@ -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
<MudStack Spacing="2">
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Dense="true">
持续活跃任务会在后台循环执行并按间隔发送内容。MaxMessages=0 表示持续运行,直到你在任务中心手动取消。
</MudAlert>
@if (loading)
{
<MudProgressCircular Indeterminate="true" Color="Color.Primary" Size="Size.Small" />
<MudText Typo="Typo.caption">正在加载账号分类...</MudText>
}
else
{
<MudSelect T="int" @bind-Value="selectedCategoryId" @bind-Value:after="OnInputsChanged"
Label="执行账号分类" Variant="Variant.Outlined" Dense="true">
@foreach (var c in categories)
{
<MudSelectItem Value="@c.Id">@c.Name</MudSelectItem>
}
</MudSelect>
<MudTextField @bind-Value="targetsText" @bind-Value:after="OnInputsChanged"
Label="目标群组/频道(每行一个)"
Variant="Variant.Outlined"
Lines="5"
Immediate="true"
HelperText="支持:用户名(可带 at 前缀)、https://t.me/xxx、频道ID(如 -1001234567890 或 1234567890)。" />
<MudTextField @bind-Value="dictionaryText" @bind-Value:after="OnInputsChanged"
Label="词典(每行一个)"
Variant="Variant.Outlined"
Lines="6"
Immediate="true"
HelperText="任务会按“随机/队列”模式从词典中取一条消息发送。" />
<MudGrid>
<MudItem xs="12" md="4">
<MudNumericField T="int" @bind-Value="delayMinMs" @bind-Value:after="OnInputsChanged"
Label="最小间隔(ms)" Variant="Variant.Outlined" Min="0" Max="600000" />
</MudItem>
<MudItem xs="12" md="4">
<MudNumericField T="int" @bind-Value="delayMaxMs" @bind-Value:after="OnInputsChanged"
Label="最大间隔(ms)" Variant="Variant.Outlined" Min="0" Max="600000" />
</MudItem>
<MudItem xs="12" md="4">
<MudNumericField T="int" @bind-Value="maxMessages" @bind-Value:after="OnInputsChanged"
Label="最多发送条数" Variant="Variant.Outlined" Min="0" Max="1000000"
HelperText="0 表示持续运行直到取消。" />
</MudItem>
</MudGrid>
<MudGrid>
<MudItem xs="12" md="4">
<MudSelect T="string" @bind-Value="accountMode" @bind-Value:after="OnInputsChanged"
Label="账号选择模式" Variant="Variant.Outlined" Dense="true">
<MudSelectItem Value="@UserChatActiveTaskModes.Random">随机</MudSelectItem>
<MudSelectItem Value="@UserChatActiveTaskModes.Queue">队列循环</MudSelectItem>
</MudSelect>
</MudItem>
<MudItem xs="12" md="4">
<MudSelect T="string" @bind-Value="targetMode" @bind-Value:after="OnInputsChanged"
Label="目标选择模式" Variant="Variant.Outlined" Dense="true">
<MudSelectItem Value="@UserChatActiveTaskModes.Random">随机</MudSelectItem>
<MudSelectItem Value="@UserChatActiveTaskModes.Queue">队列循环</MudSelectItem>
</MudSelect>
</MudItem>
<MudItem xs="12" md="4">
<MudSelect T="string" @bind-Value="messageMode" @bind-Value:after="OnInputsChanged"
Label="词典取值模式" Variant="Variant.Outlined" Dense="true">
<MudSelectItem Value="@UserChatActiveTaskModes.Random">随机</MudSelectItem>
<MudSelectItem Value="@UserChatActiveTaskModes.Queue">队列循环</MudSelectItem>
</MudSelect>
</MudItem>
</MudGrid>
}
</MudStack>
@code {
[Parameter] public ModuleTaskDraft Draft { get; set; }
[Parameter] public EventCallback<ModuleTaskDraft> DraftChanged { get; set; }
private List<AccountCategory> 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<string> ParseTargetLines(string? text)
{
if (string.IsNullOrWhiteSpace(text))
return new List<string>();
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<string> ParseMessageLines(string? text)
{
if (string.IsNullOrWhiteSpace(text))
return new List<string>();
return text
.Split(new[] { "\r\n", "\n", "\r" }, StringSplitOptions.RemoveEmptyEntries)
.Select(x => (x ?? string.Empty).Trim())
.Where(x => x.Length > 0)
.ToList();
}
}
@@ -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<string> ExtractBotAdminFailureLines(BatchTask task)
{
var lines = new List<string>();
@@ -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<string>();
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(
@@ -32,6 +32,17 @@ public sealed class TaskCatalogModule : ITelegramPanelModule, IModuleTaskProvide
public IEnumerable<ModuleTaskDefinition> 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",
+1
View File
@@ -417,6 +417,7 @@ builder.Services.Configure<PanelTimeZoneOptions>(builder.Configuration.GetSectio
builder.Services.AddSingleton<PanelTimeZoneService>();
builder.Services.AddScoped<IModuleTaskHandler, BotChannelSetAdminsByAccountTaskHandler>();
builder.Services.AddScoped<IModuleTaskHandler, BotSetAdminsTaskHandler>();
builder.Services.AddScoped<IModuleTaskHandler, UserChatActiveTaskHandler>();
builder.Services.AddHostedService<BatchTaskBackgroundService>();
builder.Services.AddHostedService<AccountDataAutoSyncBackgroundService>();
builder.Services.AddHostedService<BotAutoSyncBackgroundService>();
@@ -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<ILogger<UserChatActiveTaskHandler>>();
var taskManagement = host.Services.GetRequiredService<BatchTaskManagementService>();
var accountManagement = host.Services.GetRequiredService<AccountManagementService>();
var accountTools = host.Services.GetRequiredService<AccountTelegramToolsService>();
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<AccountSlot>();
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<int, int>();
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<UserChatActiveTaskConfig>(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<UserChatActiveTaskRuntimeFailure>();
}
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<UserChatActiveTaskRuntimeFailure>();
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<TargetSlot> 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; }
}
}
@@ -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<string> Targets { get; set; } = new();
[JsonPropertyName("dictionary")]
public List<string> 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<UserChatActiveTaskRuntimeFailure> 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; } = "";
}