feat: 任务中心新增持续活跃任务
This commit is contained in:
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",
|
||||
|
||||
@@ -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; } = "";
|
||||
}
|
||||
Reference in new issue
Block a user