chore: 删除 message report 模块
This commit is contained in:
1 parent
58273b12cd
commit
dfbf86230d
19 files changed
-3050
No files matched your search
@@ -10,12 +10,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TelegramPanel.Data", "src\T
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TelegramPanel.Modules.Abstractions", "src\TelegramPanel.Modules.Abstractions\TelegramPanel.Modules.Abstractions.csproj", "{D47E13D1-8E3C-4F40-9B6D-4B8E17A8C5F1}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{FDD46A28-5D10-4E16-A8B7-9C8612500D02}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TelegramPanel.Modules.MessageReport", "src\TelegramPanel.Modules.MessageReport\TelegramPanel.Modules.MessageReport.csproj", "{21B1CE10-B6E1-46FB-B580-16065D18EDD3}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TelegramPanel.Modules.MessageReportTask", "src\TelegramPanel.Modules.MessageReportTask\TelegramPanel.Modules.MessageReportTask.csproj", "{E48333AF-2411-4219-AD2F-6ABF45960100}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -38,17 +32,5 @@ Global
|
||||
{D47E13D1-8E3C-4F40-9B6D-4B8E17A8C5F1}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{D47E13D1-8E3C-4F40-9B6D-4B8E17A8C5F1}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{D47E13D1-8E3C-4F40-9B6D-4B8E17A8C5F1}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{21B1CE10-B6E1-46FB-B580-16065D18EDD3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{21B1CE10-B6E1-46FB-B580-16065D18EDD3}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{21B1CE10-B6E1-46FB-B580-16065D18EDD3}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{21B1CE10-B6E1-46FB-B580-16065D18EDD3}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{E48333AF-2411-4219-AD2F-6ABF45960100}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{E48333AF-2411-4219-AD2F-6ABF45960100}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{E48333AF-2411-4219-AD2F-6ABF45960100}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{E48333AF-2411-4219-AD2F-6ABF45960100}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{21B1CE10-B6E1-46FB-B580-16065D18EDD3} = {FDD46A28-5D10-4E16-A8B7-9C8612500D02}
|
||||
{E48333AF-2411-4219-AD2F-6ABF45960100} = {FDD46A28-5D10-4E16-A8B7-9C8612500D02}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
@@ -1,312 +0,0 @@
|
||||
@using System.Text.Json
|
||||
@using TelegramPanel.Core.Services
|
||||
@using TelegramPanel.Data.Entities
|
||||
@inject AccountCategoryManagementService CategoryManagement
|
||||
@inject AccountManagementService AccountManagement
|
||||
|
||||
<MudStack Spacing="2">
|
||||
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Dense="true">
|
||||
该任务会按账号分类轮流举报指定消息。<b>MaxReports=0</b> 表示持续运行直到你在任务中心手动暂停/停止;如果在外层选择 <b>Cron 计划</b>,建议配合有限次数使用。
|
||||
</MudAlert>
|
||||
|
||||
@if (loading)
|
||||
{
|
||||
<MudProgressCircular Indeterminate="true" Color="Color.Primary" Size="Size.Small" />
|
||||
<MudText Typo="Typo.caption">正在加载账号分类...</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudSelect T="int"
|
||||
Label="执行账号分类(可多选)"
|
||||
Variant="Variant.Outlined"
|
||||
Dense="true"
|
||||
MultiSelection="true"
|
||||
SelectedValues="selectedCategoryIds"
|
||||
SelectedValuesChanged="OnSelectedCategoriesChanged"
|
||||
HelperText="会从所有选中分类中抽取可用账号轮流执行举报。">
|
||||
@foreach (var category in categories)
|
||||
{
|
||||
<MudSelectItem Value="@category.Id">@category.Name</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||||
当前选中分类下可用账号数:@selectedAccountCount
|
||||
</MudText>
|
||||
|
||||
<MudTextField @bind-Value="messageReferencesText" @bind-Value:after="OnInputsChanged"
|
||||
Label="消息链接 / 消息引用(每行一个)"
|
||||
Variant="Variant.Outlined"
|
||||
Lines="6"
|
||||
Immediate="true"
|
||||
HelperText="支持:https://t.me/username/123、t.me/c/1234567890/456、用户名/789、-1001234567890/321" />
|
||||
|
||||
<MudGrid>
|
||||
<MudItem xs="12" md="4">
|
||||
<MudNumericField T="int" @bind-Value="delayMinSeconds" @bind-Value:after="OnInputsChanged"
|
||||
Label="最小间隔(秒)" Variant="Variant.Outlined" Min="0" Max="600" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" md="4">
|
||||
<MudNumericField T="int" @bind-Value="delayMaxSeconds" @bind-Value:after="OnInputsChanged"
|
||||
Label="最大间隔(秒)" Variant="Variant.Outlined" Min="0" Max="600" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" md="4">
|
||||
<MudNumericField T="int" @bind-Value="maxReports" @bind-Value:after="OnInputsChanged"
|
||||
Label="最多举报次数" Variant="Variant.Outlined" Min="0" Max="1000000"
|
||||
HelperText="0 表示持续执行直到手动停止。" />
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
|
||||
<MudSelect T="string" @bind-Value="reportType" @bind-Value:after="OnInputsChanged"
|
||||
Label="举报类型偏好" Variant="Variant.Outlined" Dense="true">
|
||||
<MudSelectItem Value="@MessageReportTaskReportTypes.Spam">垃圾/广告</MudSelectItem>
|
||||
<MudSelectItem Value="@MessageReportTaskReportTypes.Violence">暴力</MudSelectItem>
|
||||
<MudSelectItem Value="@MessageReportTaskReportTypes.Pornography">色情/成人</MudSelectItem>
|
||||
<MudSelectItem Value="@MessageReportTaskReportTypes.ChildAbuse">儿童/未成年相关</MudSelectItem>
|
||||
<MudSelectItem Value="@MessageReportTaskReportTypes.Copyright">版权/侵权</MudSelectItem>
|
||||
<MudSelectItem Value="@MessageReportTaskReportTypes.PersonalDetails">隐私/个人信息</MudSelectItem>
|
||||
<MudSelectItem Value="@MessageReportTaskReportTypes.IllegalDrugs">毒品/违禁药</MudSelectItem>
|
||||
<MudSelectItem Value="@MessageReportTaskReportTypes.Fake">诈骗/冒充/虚假</MudSelectItem>
|
||||
<MudSelectItem Value="@MessageReportTaskReportTypes.Other">其他</MudSelectItem>
|
||||
<MudSelectItem Value="@MessageReportTaskReportTypes.Auto">自动(取 Telegram 返回的首项)</MudSelectItem>
|
||||
</MudSelect>
|
||||
|
||||
<MudTextField @bind-Value="optionKeywordsText" @bind-Value:after="OnInputsChanged"
|
||||
Label="举报选项匹配关键词(可选,每行一个)"
|
||||
Variant="Variant.Outlined"
|
||||
Lines="3"
|
||||
Immediate="true"
|
||||
HelperText="当 Telegram 返回动态举报菜单时,会优先按“举报类型偏好 + 这里的关键词”去匹配选项文本。" />
|
||||
|
||||
<MudTextField @bind-Value="commentsText" @bind-Value:after="OnInputsChanged"
|
||||
Label="举报文案(可选,每行一个)"
|
||||
Variant="Variant.Outlined"
|
||||
Lines="4"
|
||||
Immediate="true"
|
||||
HelperText="仅当 Telegram 要求补充举报说明时才会使用;留空则只在“可跳过评论”时继续。" />
|
||||
|
||||
<MudSelect T="string" @bind-Value="commentMode" @bind-Value:after="OnInputsChanged"
|
||||
Label="举报文案取值模式" Variant="Variant.Outlined" Dense="true">
|
||||
<MudSelectItem Value="@MessageReportTaskModes.Queue">队列循环</MudSelectItem>
|
||||
<MudSelectItem Value="@MessageReportTaskModes.Random">随机</MudSelectItem>
|
||||
</MudSelect>
|
||||
}
|
||||
</MudStack>
|
||||
|
||||
@code {
|
||||
[Parameter] public ModuleTaskDraft Draft { get; set; }
|
||||
[Parameter] public EventCallback<ModuleTaskDraft> DraftChanged { get; set; }
|
||||
[Parameter] public string? InitialConfigJson { get; set; }
|
||||
|
||||
private List<AccountCategory> categories = new();
|
||||
private List<Account> activeAccounts = new();
|
||||
private bool loading = true;
|
||||
private bool initializedFromConfig;
|
||||
|
||||
private IEnumerable<int> selectedCategoryIds = Array.Empty<int>();
|
||||
private string messageReferencesText = string.Empty;
|
||||
private int delayMinSeconds = 15;
|
||||
private int delayMaxSeconds = 45;
|
||||
private int maxReports;
|
||||
private string reportType = MessageReportTaskReportTypes.Spam;
|
||||
private string optionKeywordsText = string.Empty;
|
||||
private string commentsText = string.Empty;
|
||||
private string commentMode = MessageReportTaskModes.Queue;
|
||||
|
||||
private int runtimeCompletedAttempts;
|
||||
private int runtimeFailedAttempts;
|
||||
private int runtimeAccountCursor;
|
||||
private int runtimeMessageCursor;
|
||||
private int runtimeCommentCursor;
|
||||
private List<MessageReportTaskRuntimeFailure> runtimeRecentFailures = new();
|
||||
|
||||
private int selectedAccountCount => activeAccounts.Count(x =>
|
||||
x.IsActive
|
||||
&& x.UserId > 0
|
||||
&& x.CategoryId.HasValue
|
||||
&& selectedCategoryIds.Contains(x.CategoryId.Value)
|
||||
&& x.Category?.ExcludeFromOperations != true);
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
ApplyInitialConfigIfNeeded();
|
||||
|
||||
try
|
||||
{
|
||||
var categoriesTask = CategoryManagement.GetAllCategoriesAsync();
|
||||
var accountsTask = AccountManagement.GetAllAccountsAsync();
|
||||
await Task.WhenAll(categoriesTask, accountsTask);
|
||||
|
||||
categories = categoriesTask.Result
|
||||
.Where(x => !x.ExcludeFromOperations)
|
||||
.OrderBy(x => x.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
activeAccounts = accountsTask.Result.ToList();
|
||||
|
||||
if (!selectedCategoryIds.Any())
|
||||
{
|
||||
var firstCategoryId = categories.FirstOrDefault()?.Id ?? 0;
|
||||
selectedCategoryIds = firstCategoryId > 0 ? new[] { firstCategoryId } : Array.Empty<int>();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
loading = false;
|
||||
await PushDraftAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyInitialConfigIfNeeded()
|
||||
{
|
||||
if (initializedFromConfig)
|
||||
return;
|
||||
|
||||
initializedFromConfig = true;
|
||||
var raw = (InitialConfigJson ?? string.Empty).Trim();
|
||||
if (raw.Length == 0)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
var cfg = JsonSerializer.Deserialize<MessageReportTaskConfig>(raw);
|
||||
if (cfg == null)
|
||||
return;
|
||||
|
||||
var categoryIds = MessageReportTaskInputHelper.NormalizeCategoryIds(cfg.CategoryIds, cfg.CategoryId);
|
||||
if (categoryIds.Count > 0)
|
||||
selectedCategoryIds = categoryIds;
|
||||
|
||||
messageReferencesText = string.Join(Environment.NewLine, cfg.MessageReferences ?? new List<string>());
|
||||
delayMinSeconds = ConvertMillisecondsToSeconds(cfg.DelayMinMs);
|
||||
delayMaxSeconds = ConvertMillisecondsToSeconds(cfg.DelayMaxMs);
|
||||
maxReports = Math.Max(0, cfg.MaxReports);
|
||||
reportType = MessageReportTaskReportTypes.Normalize(cfg.ReportType);
|
||||
optionKeywordsText = string.Join(Environment.NewLine, cfg.OptionKeywords ?? new List<string>());
|
||||
commentsText = string.Join(Environment.NewLine, cfg.Comments ?? new List<string>());
|
||||
commentMode = MessageReportTaskModes.Normalize(cfg.CommentMode);
|
||||
|
||||
runtimeCompletedAttempts = Math.Max(0, cfg.CompletedAttempts);
|
||||
runtimeFailedAttempts = Math.Max(0, cfg.FailedAttempts);
|
||||
runtimeAccountCursor = Math.Max(0, cfg.AccountCursor);
|
||||
runtimeMessageCursor = Math.Max(0, cfg.MessageCursor);
|
||||
runtimeCommentCursor = Math.Max(0, cfg.CommentCursor);
|
||||
runtimeRecentFailures = cfg.RecentFailures?.TakeLast(20).ToList() ?? new List<MessageReportTaskRuntimeFailure>();
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OnSelectedCategoriesChanged(IEnumerable<int> values)
|
||||
{
|
||||
selectedCategoryIds = MessageReportTaskInputHelper.NormalizeCategoryIds(values, 0);
|
||||
await OnInputsChanged();
|
||||
}
|
||||
|
||||
private async Task OnInputsChanged()
|
||||
{
|
||||
await PushDraftAsync();
|
||||
}
|
||||
|
||||
private async Task PushDraftAsync()
|
||||
{
|
||||
var selectedIds = MessageReportTaskInputHelper.NormalizeCategoryIds(selectedCategoryIds, 0);
|
||||
var selectedCategories = categories.Where(x => selectedIds.Contains(x.Id)).ToList();
|
||||
var messageReferences = MessageReportTaskInputHelper.ParseMultilineText(messageReferencesText, distinct: true);
|
||||
var optionKeywords = MessageReportTaskInputHelper.ParseMultilineText(optionKeywordsText, distinct: true);
|
||||
var comments = MessageReportTaskInputHelper.ParseMultilineText(commentsText, distinct: false);
|
||||
|
||||
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 (selectedCategories.Count == 0)
|
||||
{
|
||||
await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "请至少选择一个执行账号分类"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedAccountCount <= 0)
|
||||
{
|
||||
await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "所选分类下没有可用执行账号"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (messageReferences.Count == 0)
|
||||
{
|
||||
await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "请至少填写一个消息引用"));
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var reference in messageReferences)
|
||||
{
|
||||
if (!MessageReportTaskInputHelper.TryParseMessageReference(reference, out _, out var error))
|
||||
{
|
||||
await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, $"消息引用无效:{reference}({error})"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (delayMinSeconds < 0 || delayMaxSeconds < 0)
|
||||
{
|
||||
await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "间隔不能为负数"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (delayMaxSeconds < delayMinSeconds)
|
||||
{
|
||||
await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "最大间隔不能小于最小间隔"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (maxReports < 0)
|
||||
{
|
||||
await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "最多举报次数不能小于 0"));
|
||||
return;
|
||||
}
|
||||
|
||||
var cfg = new MessageReportTaskConfig
|
||||
{
|
||||
CategoryId = selectedCategories[0].Id,
|
||||
CategoryName = selectedCategories[0].Name,
|
||||
CategoryIds = selectedCategories.Select(x => x.Id).ToList(),
|
||||
CategoryNames = selectedCategories.Select(x => x.Name).ToList(),
|
||||
MessageReferences = messageReferences,
|
||||
DelayMinMs = delayMinSeconds * 1000,
|
||||
DelayMaxMs = delayMaxSeconds * 1000,
|
||||
MaxReports = maxReports,
|
||||
ReportType = MessageReportTaskReportTypes.Normalize(reportType),
|
||||
OptionKeywords = optionKeywords,
|
||||
Comments = comments,
|
||||
CommentMode = MessageReportTaskModes.Normalize(commentMode),
|
||||
CompletedAttempts = runtimeCompletedAttempts,
|
||||
FailedAttempts = runtimeFailedAttempts,
|
||||
AccountCursor = runtimeAccountCursor,
|
||||
MessageCursor = runtimeMessageCursor,
|
||||
CommentCursor = runtimeCommentCursor,
|
||||
Canceled = false,
|
||||
Error = null,
|
||||
RecentFailures = runtimeRecentFailures.ToList()
|
||||
};
|
||||
|
||||
var total = maxReports > 0 ? maxReports : 0;
|
||||
var json = JsonSerializer.Serialize(cfg, new JsonSerializerOptions { WriteIndented = true });
|
||||
await DraftChanged.InvokeAsync(new ModuleTaskDraft(total, json, true, null));
|
||||
}
|
||||
|
||||
private static int ConvertMillisecondsToSeconds(int milliseconds)
|
||||
{
|
||||
if (milliseconds <= 0)
|
||||
return 0;
|
||||
return (int)Math.Ceiling(milliseconds / 1000d);
|
||||
}
|
||||
}
|
||||
@@ -1,312 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using TelegramPanel.Core.Services;
|
||||
using TelegramPanel.Core.Services.Telegram;
|
||||
using TelegramPanel.Data.Entities;
|
||||
using TelegramPanel.Modules;
|
||||
|
||||
namespace TelegramPanel.Modules.MessageReport;
|
||||
|
||||
public sealed class MessageReportTaskHandler : IModuleTaskHandler
|
||||
{
|
||||
private const int MaxFailureLines = 20;
|
||||
|
||||
public string TaskType => MessageReportTaskTypes.TaskType;
|
||||
|
||||
public async Task ExecuteAsync(IModuleTaskExecutionHost host, CancellationToken cancellationToken)
|
||||
{
|
||||
var logger = host.Services.GetRequiredService<ILogger<MessageReportTaskHandler>>();
|
||||
var taskManagement = host.Services.GetRequiredService<BatchTaskManagementService>();
|
||||
var accountManagement = host.Services.GetRequiredService<AccountManagementService>();
|
||||
var telegramService = host.Services.GetRequiredService<MessageReportTaskTelegramService>();
|
||||
|
||||
var config = DeserializeConfig(host.Config);
|
||||
ValidateAndNormalizeConfig(config);
|
||||
|
||||
var selectedCategoryIds = MessageReportTaskInputHelper.NormalizeCategoryIds(config.CategoryIds, config.CategoryId).ToHashSet();
|
||||
var allAccounts = (await accountManagement.GetAllAccountsAsync())
|
||||
.Where(x => x.IsActive && x.UserId > 0 && x.Category?.ExcludeFromOperations != true)
|
||||
.Where(x => x.CategoryId.HasValue && selectedCategoryIds.Contains(x.CategoryId.Value))
|
||||
.OrderBy(x => x.Id)
|
||||
.ToList();
|
||||
|
||||
if (allAccounts.Count == 0)
|
||||
{
|
||||
config.Error = "所选分类下没有可用执行账号";
|
||||
await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(config));
|
||||
throw new InvalidOperationException(config.Error);
|
||||
}
|
||||
|
||||
var parsedMessageReferences = config.MessageReferences
|
||||
.Select(raw =>
|
||||
{
|
||||
if (!MessageReportTaskInputHelper.TryParseMessageReference(raw, out var parsed, out var error) || parsed == null)
|
||||
throw new InvalidOperationException($"消息引用无效:{raw}({error})");
|
||||
return parsed;
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var completed = Math.Max(0, config.CompletedAttempts);
|
||||
var failed = Math.Max(0, config.FailedAttempts);
|
||||
var accountCursor = NormalizeCursor(config.AccountCursor, allAccounts.Count);
|
||||
var messageCursor = NormalizeCursor(config.MessageCursor, parsedMessageReferences.Count);
|
||||
var commentCursor = Math.Max(0, config.CommentCursor);
|
||||
var resolvedCache = new Dictionary<string, ResolvedMessageTarget>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
config.Canceled = false;
|
||||
config.Error = null;
|
||||
config.CompletedAttempts = completed;
|
||||
config.FailedAttempts = failed;
|
||||
config.AccountCursor = accountCursor;
|
||||
config.MessageCursor = messageCursor;
|
||||
config.CommentCursor = commentCursor;
|
||||
await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(config));
|
||||
await host.UpdateProgressAsync(completed, failed, cancellationToken);
|
||||
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (!await host.IsStillRunningAsync(cancellationToken))
|
||||
{
|
||||
config.Canceled = true;
|
||||
config.Error = null;
|
||||
config.CompletedAttempts = completed;
|
||||
config.FailedAttempts = failed;
|
||||
config.AccountCursor = accountCursor;
|
||||
config.MessageCursor = messageCursor;
|
||||
config.CommentCursor = commentCursor;
|
||||
await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(config));
|
||||
return;
|
||||
}
|
||||
|
||||
if (config.MaxReports > 0 && completed >= config.MaxReports)
|
||||
break;
|
||||
|
||||
var account = allAccounts[SelectIndex(allAccounts.Count, ref accountCursor)];
|
||||
var messageReference = parsedMessageReferences[SelectIndex(parsedMessageReferences.Count, ref messageCursor)];
|
||||
var cacheKey = $"{account.Id}:{messageReference.CanonicalId}";
|
||||
|
||||
string? failureReason = null;
|
||||
try
|
||||
{
|
||||
if (!resolvedCache.TryGetValue(cacheKey, out var resolvedTarget))
|
||||
{
|
||||
var resolved = await telegramService.ResolveMessageReferenceAsync(account.Id, messageReference.Raw, cancellationToken);
|
||||
if (!resolved.Success || resolved.Target == null)
|
||||
{
|
||||
failureReason = NormalizeReason(resolved.Error);
|
||||
}
|
||||
else
|
||||
{
|
||||
resolvedTarget = resolved.Target;
|
||||
resolvedCache[cacheKey] = resolvedTarget;
|
||||
}
|
||||
}
|
||||
|
||||
if (failureReason == null && resolvedCache.TryGetValue(cacheKey, out var cachedTarget))
|
||||
{
|
||||
var report = await telegramService.ReportMessageAsync(account.Id, cachedTarget, config, commentCursor, cancellationToken);
|
||||
commentCursor = Math.Max(0, report.NextCommentCursor);
|
||||
if (!string.IsNullOrWhiteSpace(report.SelectedOptionText))
|
||||
logger.LogDebug("Message report option selected: {OptionText}", report.SelectedOptionText);
|
||||
if (!report.Success)
|
||||
{
|
||||
failureReason = NormalizeReason(report.Error);
|
||||
if (LooksLikePeerInvalid(report.Error))
|
||||
resolvedCache.Remove(cacheKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Message report task iteration failed (taskId={TaskId}, accountId={AccountId}, message={Message})", host.TaskId, account.Id, messageReference.CanonicalId);
|
||||
var mapped = AccountTelegramToolsService.MapTelegramException(ex);
|
||||
failureReason = NormalizeReason(string.IsNullOrWhiteSpace(mapped.details) ? mapped.summary : $"{mapped.summary}:{mapped.details}");
|
||||
}
|
||||
|
||||
completed++;
|
||||
if (!string.IsNullOrWhiteSpace(failureReason))
|
||||
{
|
||||
failed++;
|
||||
AddFailure(config, account, messageReference.Raw, failureReason!);
|
||||
}
|
||||
|
||||
config.Canceled = false;
|
||||
config.Error = null;
|
||||
config.CompletedAttempts = completed;
|
||||
config.FailedAttempts = failed;
|
||||
config.AccountCursor = accountCursor;
|
||||
config.MessageCursor = messageCursor;
|
||||
config.CommentCursor = commentCursor;
|
||||
await host.UpdateProgressAsync(completed, failed, cancellationToken);
|
||||
await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(config));
|
||||
|
||||
if (config.MaxReports > 0 && completed >= config.MaxReports)
|
||||
break;
|
||||
|
||||
if (!await DelayAsync(host, config.DelayMinMs, config.DelayMaxMs, cancellationToken))
|
||||
{
|
||||
config.Canceled = true;
|
||||
config.Error = null;
|
||||
config.CompletedAttempts = completed;
|
||||
config.FailedAttempts = failed;
|
||||
config.AccountCursor = accountCursor;
|
||||
config.MessageCursor = messageCursor;
|
||||
config.CommentCursor = commentCursor;
|
||||
await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(config));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
config.Canceled = false;
|
||||
config.Error = null;
|
||||
config.CompletedAttempts = completed;
|
||||
config.FailedAttempts = failed;
|
||||
config.AccountCursor = accountCursor;
|
||||
config.MessageCursor = messageCursor;
|
||||
config.CommentCursor = commentCursor;
|
||||
await host.UpdateProgressAsync(completed, failed, cancellationToken);
|
||||
await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(config));
|
||||
}
|
||||
|
||||
private static MessageReportTaskConfig DeserializeConfig(string? rawConfig)
|
||||
{
|
||||
var raw = (rawConfig ?? string.Empty).Trim();
|
||||
if (raw.Length == 0)
|
||||
throw new InvalidOperationException("任务配置为空");
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<MessageReportTaskConfig>(raw)
|
||||
?? throw new InvalidOperationException("任务配置解析结果为空");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new InvalidOperationException($"任务配置 JSON 无效:{ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateAndNormalizeConfig(MessageReportTaskConfig config)
|
||||
{
|
||||
config.CategoryIds = MessageReportTaskInputHelper.NormalizeCategoryIds(config.CategoryIds, config.CategoryId);
|
||||
config.CategoryNames = MessageReportTaskInputHelper.NormalizeCategoryNames(config.CategoryNames, config.CategoryName);
|
||||
config.MessageReferences = MessageReportTaskInputHelper.NormalizeLines(config.MessageReferences, distinct: true);
|
||||
config.OptionKeywords = MessageReportTaskInputHelper.NormalizeLines(config.OptionKeywords, distinct: true);
|
||||
config.Comments = MessageReportTaskInputHelper.NormalizeLines(config.Comments);
|
||||
config.CommentMode = MessageReportTaskModes.Normalize(config.CommentMode);
|
||||
config.ReportType = MessageReportTaskReportTypes.Normalize(config.ReportType);
|
||||
config.DelayMinMs = Math.Clamp(config.DelayMinMs, 0, 600000);
|
||||
config.DelayMaxMs = Math.Clamp(config.DelayMaxMs, config.DelayMinMs, 600000);
|
||||
config.MaxReports = Math.Max(0, config.MaxReports);
|
||||
config.CompletedAttempts = Math.Max(0, config.CompletedAttempts);
|
||||
config.FailedAttempts = Math.Max(0, config.FailedAttempts);
|
||||
config.AccountCursor = Math.Max(0, config.AccountCursor);
|
||||
config.MessageCursor = Math.Max(0, config.MessageCursor);
|
||||
config.CommentCursor = Math.Max(0, config.CommentCursor);
|
||||
config.RecentFailures ??= new List<MessageReportTaskRuntimeFailure>();
|
||||
|
||||
if (config.CategoryIds.Count == 0)
|
||||
throw new InvalidOperationException("请至少选择一个执行账号分类");
|
||||
|
||||
if (config.MessageReferences.Count == 0)
|
||||
throw new InvalidOperationException("请至少填写一个消息引用");
|
||||
|
||||
foreach (var messageReference in config.MessageReferences)
|
||||
{
|
||||
if (!MessageReportTaskInputHelper.TryParseMessageReference(messageReference, out _, out var error))
|
||||
throw new InvalidOperationException($"消息引用无效:{messageReference}({error})");
|
||||
}
|
||||
}
|
||||
|
||||
private static int SelectIndex(int count, ref int cursor)
|
||||
{
|
||||
if (count <= 0)
|
||||
return 0;
|
||||
|
||||
var index = NormalizeCursor(cursor, count);
|
||||
cursor = (index + 1) % count;
|
||||
return index;
|
||||
}
|
||||
|
||||
private static int NormalizeCursor(int cursor, int count)
|
||||
{
|
||||
if (count <= 0)
|
||||
return 0;
|
||||
if (cursor < 0)
|
||||
return 0;
|
||||
return cursor % count;
|
||||
}
|
||||
|
||||
private static async Task<bool> DelayAsync(IModuleTaskExecutionHost host, int minDelayMs, int maxDelayMs, CancellationToken cancellationToken)
|
||||
{
|
||||
var delayMs = NextDelayMilliseconds(minDelayMs, maxDelayMs);
|
||||
if (delayMs <= 0)
|
||||
return true;
|
||||
|
||||
var remaining = delayMs;
|
||||
while (remaining > 0)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (!await host.IsStillRunningAsync(cancellationToken))
|
||||
return false;
|
||||
|
||||
var chunk = Math.Min(remaining, 1000);
|
||||
await Task.Delay(chunk, cancellationToken);
|
||||
remaining -= chunk;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static int NextDelayMilliseconds(int minDelayMs, int maxDelayMs)
|
||||
{
|
||||
minDelayMs = Math.Max(0, minDelayMs);
|
||||
maxDelayMs = Math.Max(minDelayMs, maxDelayMs);
|
||||
return maxDelayMs <= minDelayMs
|
||||
? minDelayMs
|
||||
: Random.Shared.Next(minDelayMs, maxDelayMs + 1);
|
||||
}
|
||||
|
||||
private static void AddFailure(MessageReportTaskConfig config, Account account, string messageReference, string reason)
|
||||
{
|
||||
config.RecentFailures ??= new List<MessageReportTaskRuntimeFailure>();
|
||||
config.RecentFailures.Add(new MessageReportTaskRuntimeFailure
|
||||
{
|
||||
TimestampUtc = DateTime.UtcNow,
|
||||
AccountId = account.Id,
|
||||
AccountDisplay = BuildAccountDisplayName(account),
|
||||
MessageReference = messageReference,
|
||||
Reason = reason
|
||||
});
|
||||
|
||||
if (config.RecentFailures.Count > MaxFailureLines)
|
||||
config.RecentFailures = config.RecentFailures.TakeLast(MaxFailureLines).ToList();
|
||||
}
|
||||
|
||||
private static string BuildAccountDisplayName(Account account)
|
||||
{
|
||||
return $"#{account.Id} {(string.IsNullOrWhiteSpace(account.DisplayPhone) ? account.Phone : account.DisplayPhone)}";
|
||||
}
|
||||
|
||||
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? error)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(error) ? "Telegram 请求失败" : error.Trim();
|
||||
}
|
||||
|
||||
private static string SerializeIndented(MessageReportTaskConfig config)
|
||||
{
|
||||
return JsonSerializer.Serialize(config, new JsonSerializerOptions { WriteIndented = true });
|
||||
}
|
||||
}
|
||||
@@ -1,266 +0,0 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace TelegramPanel.Modules.MessageReport;
|
||||
|
||||
public static class MessageReportTaskTypes
|
||||
{
|
||||
public const string TaskType = "module_message_report";
|
||||
}
|
||||
|
||||
public static class MessageReportTaskModes
|
||||
{
|
||||
public const string Random = "random";
|
||||
public const string Queue = "queue";
|
||||
|
||||
public static string Normalize(string? value)
|
||||
{
|
||||
return string.Equals((value ?? string.Empty).Trim(), Queue, StringComparison.OrdinalIgnoreCase)
|
||||
? Queue
|
||||
: Random;
|
||||
}
|
||||
}
|
||||
|
||||
public static class MessageReportTaskReportTypes
|
||||
{
|
||||
public const string Auto = "auto";
|
||||
public const string Spam = "spam";
|
||||
public const string Violence = "violence";
|
||||
public const string Pornography = "pornography";
|
||||
public const string ChildAbuse = "child_abuse";
|
||||
public const string Copyright = "copyright";
|
||||
public const string PersonalDetails = "personal_details";
|
||||
public const string IllegalDrugs = "illegal_drugs";
|
||||
public const string Fake = "fake";
|
||||
public const string Other = "other";
|
||||
|
||||
public static string Normalize(string? value)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
|
||||
return normalized switch
|
||||
{
|
||||
Spam or Violence or Pornography or ChildAbuse or Copyright or PersonalDetails or IllegalDrugs or Fake or Other => normalized,
|
||||
_ => Auto
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class MessageReportTaskConfig
|
||||
{
|
||||
[JsonPropertyName("category_id")]
|
||||
public int CategoryId { get; set; }
|
||||
|
||||
[JsonPropertyName("category_name")]
|
||||
public string? CategoryName { get; set; }
|
||||
|
||||
[JsonPropertyName("category_ids")]
|
||||
public List<int> CategoryIds { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("category_names")]
|
||||
public List<string> CategoryNames { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("message_references")]
|
||||
public List<string> MessageReferences { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("delay_min_ms")]
|
||||
public int DelayMinMs { get; set; } = 15000;
|
||||
|
||||
[JsonPropertyName("delay_max_ms")]
|
||||
public int DelayMaxMs { get; set; } = 45000;
|
||||
|
||||
[JsonPropertyName("max_reports")]
|
||||
public int MaxReports { get; set; }
|
||||
|
||||
[JsonPropertyName("report_type")]
|
||||
public string ReportType { get; set; } = MessageReportTaskReportTypes.Spam;
|
||||
|
||||
[JsonPropertyName("option_keywords")]
|
||||
public List<string> OptionKeywords { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("comments")]
|
||||
public List<string> Comments { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("comment_mode")]
|
||||
public string CommentMode { get; set; } = MessageReportTaskModes.Queue;
|
||||
|
||||
[JsonPropertyName("completed_attempts")]
|
||||
public int CompletedAttempts { get; set; }
|
||||
|
||||
[JsonPropertyName("failed_attempts")]
|
||||
public int FailedAttempts { get; set; }
|
||||
|
||||
[JsonPropertyName("account_cursor")]
|
||||
public int AccountCursor { get; set; }
|
||||
|
||||
[JsonPropertyName("message_cursor")]
|
||||
public int MessageCursor { get; set; }
|
||||
|
||||
[JsonPropertyName("comment_cursor")]
|
||||
public int CommentCursor { get; set; }
|
||||
|
||||
[JsonPropertyName("canceled")]
|
||||
public bool Canceled { get; set; }
|
||||
|
||||
[JsonPropertyName("error")]
|
||||
public string? Error { get; set; }
|
||||
|
||||
[JsonPropertyName("recent_failures")]
|
||||
public List<MessageReportTaskRuntimeFailure> RecentFailures { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class MessageReportTaskRuntimeFailure
|
||||
{
|
||||
[JsonPropertyName("timestamp_utc")]
|
||||
public DateTime TimestampUtc { get; set; } = DateTime.UtcNow;
|
||||
|
||||
[JsonPropertyName("account_id")]
|
||||
public int AccountId { get; set; }
|
||||
|
||||
[JsonPropertyName("account_display")]
|
||||
public string AccountDisplay { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("message_reference")]
|
||||
public string MessageReference { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("reason")]
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed record ParsedMessageReference(string Raw, string Target, int MessageId, string CanonicalId);
|
||||
|
||||
public static class MessageReportTaskInputHelper
|
||||
{
|
||||
public static List<int> NormalizeCategoryIds(IEnumerable<int>? values, int fallback)
|
||||
{
|
||||
var ids = (values ?? Array.Empty<int>())
|
||||
.Where(x => x > 0)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
if (ids.Count == 0 && fallback > 0)
|
||||
ids.Add(fallback);
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
public static List<string> NormalizeCategoryNames(IEnumerable<string>? values, string? fallback)
|
||||
{
|
||||
var names = NormalizeLines(values, distinct: true);
|
||||
var fallbackName = (fallback ?? string.Empty).Trim();
|
||||
if (names.Count == 0 && fallbackName.Length > 0)
|
||||
names.Add(fallbackName);
|
||||
return names;
|
||||
}
|
||||
|
||||
public static List<string> NormalizeLines(IEnumerable<string>? values, bool distinct = false)
|
||||
{
|
||||
var lines = (values ?? Array.Empty<string>())
|
||||
.Select(x => (x ?? string.Empty).Trim())
|
||||
.Where(x => x.Length > 0)
|
||||
.ToList();
|
||||
|
||||
return distinct
|
||||
? lines.Distinct(StringComparer.OrdinalIgnoreCase).ToList()
|
||||
: lines;
|
||||
}
|
||||
|
||||
public static List<string> ParseMultilineText(string? text, bool distinct = false)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
return new List<string>();
|
||||
|
||||
return NormalizeLines(
|
||||
text.Replace("\r", string.Empty).Split('\n', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries),
|
||||
distinct);
|
||||
}
|
||||
|
||||
public static bool TryParseMessageReference(string? raw, out ParsedMessageReference? parsed, out string? error)
|
||||
{
|
||||
parsed = null;
|
||||
error = null;
|
||||
|
||||
var value = (raw ?? string.Empty).Trim();
|
||||
if (value.Length == 0)
|
||||
{
|
||||
error = "消息引用不能为空";
|
||||
return false;
|
||||
}
|
||||
|
||||
value = NormalizeTelegramPath(value);
|
||||
if (value.Length == 0)
|
||||
{
|
||||
error = "消息引用格式无效";
|
||||
return false;
|
||||
}
|
||||
|
||||
var parts = value.Split('/', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
|
||||
string target;
|
||||
string messageIdText;
|
||||
|
||||
if (parts.Length >= 3 && string.Equals(parts[0], "c", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (!long.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var internalChatId) || internalChatId <= 0)
|
||||
{
|
||||
error = "t.me/c 链接中的频道 ID 无效";
|
||||
return false;
|
||||
}
|
||||
|
||||
target = $"-100{internalChatId}";
|
||||
messageIdText = parts[2];
|
||||
}
|
||||
else if (parts.Length >= 2)
|
||||
{
|
||||
target = parts[0];
|
||||
messageIdText = parts[1];
|
||||
}
|
||||
else
|
||||
{
|
||||
error = "消息引用格式无效,应类似 username/123 或 t.me/c/1234567890/123";
|
||||
return false;
|
||||
}
|
||||
|
||||
target = target.Trim().TrimStart('@');
|
||||
if (target.Length == 0)
|
||||
{
|
||||
error = "消息引用缺少目标";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!int.TryParse(messageIdText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var messageId) || messageId <= 0)
|
||||
{
|
||||
error = "消息 ID 无效,必须是正整数";
|
||||
return false;
|
||||
}
|
||||
|
||||
parsed = new ParsedMessageReference((raw ?? string.Empty).Trim(), target, messageId, $"{target}/{messageId}");
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string NormalizeTelegramPath(string value)
|
||||
{
|
||||
var raw = value.Trim();
|
||||
if (Uri.TryCreate(raw, UriKind.Absolute, out var absolute))
|
||||
{
|
||||
var host = (absolute.Host ?? string.Empty).Trim().ToLowerInvariant();
|
||||
if (host is not ("t.me" or "www.t.me" or "telegram.me" or "www.telegram.me"))
|
||||
return string.Empty;
|
||||
|
||||
raw = absolute.PathAndQuery;
|
||||
}
|
||||
else if (raw.StartsWith("t.me/", StringComparison.OrdinalIgnoreCase)
|
||||
|| raw.StartsWith("telegram.me/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
raw = raw[(raw.IndexOf('/') + 1)..];
|
||||
}
|
||||
|
||||
var questionMarkIndex = raw.IndexOf('?');
|
||||
if (questionMarkIndex >= 0)
|
||||
raw = raw[..questionMarkIndex];
|
||||
|
||||
raw = raw.Trim().Trim('/').Replace('\\', '/');
|
||||
if (raw.StartsWith("s/", StringComparison.OrdinalIgnoreCase))
|
||||
raw = raw[2..];
|
||||
|
||||
return raw.Trim('/');
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using MudBlazor;
|
||||
using TelegramPanel.Modules;
|
||||
|
||||
namespace TelegramPanel.Modules.MessageReport;
|
||||
|
||||
public sealed class MessageReportTaskModule : ITelegramPanelModule, IModuleTaskProvider
|
||||
{
|
||||
public ModuleManifest Manifest { get; } = new()
|
||||
{
|
||||
Id = "task.message-report",
|
||||
Name = "任务:消息举报",
|
||||
Version = "1.0.0",
|
||||
Host = new HostCompatibility { Min = "0.0.0", Max = "2.0.0" },
|
||||
Entry = new ModuleEntryPoint
|
||||
{
|
||||
Assembly = "TelegramPanel.Modules.MessageReport.dll",
|
||||
Type = typeof(MessageReportTaskModule).FullName ?? string.Empty
|
||||
}
|
||||
};
|
||||
|
||||
public void ConfigureServices(IServiceCollection services, ModuleHostContext context)
|
||||
{
|
||||
services.AddScoped<MessageReportTaskTelegramService>();
|
||||
services.AddScoped<IModuleTaskHandler, MessageReportTaskHandler>();
|
||||
services.AddSingleton<IModuleTaskRerunBuilder, MessageReportTaskRerunBuilder>();
|
||||
}
|
||||
|
||||
public void MapEndpoints(IEndpointRouteBuilder endpoints, ModuleHostContext context)
|
||||
{
|
||||
}
|
||||
|
||||
public IEnumerable<ModuleTaskDefinition> GetTasks(ModuleHostContext context)
|
||||
{
|
||||
yield return new ModuleTaskDefinition
|
||||
{
|
||||
Category = "user",
|
||||
TaskType = MessageReportTaskTypes.TaskType,
|
||||
DisplayName = "消息举报(多账号)",
|
||||
Description = "按账号分类持续举报指定消息,支持间隔控制、达到次数后停止,也支持 Cron 计划触发。",
|
||||
Icon = Icons.Material.Filled.Warning,
|
||||
EditorComponentType = typeof(MessageReportTaskEditor).AssemblyQualifiedName ?? string.Empty,
|
||||
TaskCenter = new ModuleTaskCenterCapabilities
|
||||
{
|
||||
CanPause = true,
|
||||
CanResume = true,
|
||||
CanEdit = true,
|
||||
CanRerun = true,
|
||||
EditComponentType = typeof(MessageReportTaskEditor).AssemblyQualifiedName ?? string.Empty,
|
||||
AutoPauseBeforeEdit = true
|
||||
},
|
||||
Order = 160
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using TelegramPanel.Modules;
|
||||
|
||||
namespace TelegramPanel.Modules.MessageReport;
|
||||
|
||||
public sealed class MessageReportTaskRerunBuilder : IModuleTaskRerunBuilder
|
||||
{
|
||||
public string TaskType => MessageReportTaskTypes.TaskType;
|
||||
|
||||
public ModuleTaskCreateRequest Build(ModuleTaskSnapshot task)
|
||||
{
|
||||
var rerunConfig = BuildRerunConfig(task.Config);
|
||||
var total = rerunConfig.MaxReports > 0 ? rerunConfig.MaxReports : 0;
|
||||
|
||||
return new ModuleTaskCreateRequest
|
||||
{
|
||||
TaskType = MessageReportTaskTypes.TaskType,
|
||||
Total = total,
|
||||
Config = JsonSerializer.Serialize(rerunConfig, new JsonSerializerOptions { WriteIndented = true })
|
||||
};
|
||||
}
|
||||
|
||||
private static MessageReportTaskConfig BuildRerunConfig(string? rawConfig)
|
||||
{
|
||||
var raw = (rawConfig ?? string.Empty).Trim();
|
||||
if (raw.Length == 0)
|
||||
throw new InvalidOperationException("任务配置为空,无法重新运行");
|
||||
|
||||
MessageReportTaskConfig cfg;
|
||||
try
|
||||
{
|
||||
cfg = JsonSerializer.Deserialize<MessageReportTaskConfig>(raw)
|
||||
?? throw new InvalidOperationException("任务配置解析结果为空");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new InvalidOperationException($"任务配置 JSON 无效:{ex.Message}");
|
||||
}
|
||||
|
||||
cfg.CategoryIds = MessageReportTaskInputHelper.NormalizeCategoryIds(cfg.CategoryIds, cfg.CategoryId);
|
||||
if (cfg.CategoryIds.Count == 0)
|
||||
throw new InvalidOperationException("任务缺少账号分类,无法重新运行");
|
||||
|
||||
cfg.CategoryNames = MessageReportTaskInputHelper.NormalizeCategoryNames(cfg.CategoryNames, cfg.CategoryName);
|
||||
cfg.CategoryId = cfg.CategoryIds[0];
|
||||
cfg.CategoryName = cfg.CategoryNames.FirstOrDefault() ?? cfg.CategoryName;
|
||||
cfg.MessageReferences = MessageReportTaskInputHelper.NormalizeLines(cfg.MessageReferences, distinct: true);
|
||||
cfg.OptionKeywords = MessageReportTaskInputHelper.NormalizeLines(cfg.OptionKeywords, distinct: true);
|
||||
cfg.Comments = MessageReportTaskInputHelper.NormalizeLines(cfg.Comments);
|
||||
cfg.ReportType = MessageReportTaskReportTypes.Normalize(cfg.ReportType);
|
||||
cfg.CommentMode = MessageReportTaskModes.Normalize(cfg.CommentMode);
|
||||
|
||||
foreach (var messageReference in cfg.MessageReferences)
|
||||
{
|
||||
if (!MessageReportTaskInputHelper.TryParseMessageReference(messageReference, out _, out var error))
|
||||
throw new InvalidOperationException($"消息引用无效:{messageReference}({error})");
|
||||
}
|
||||
|
||||
if (cfg.MessageReferences.Count == 0)
|
||||
throw new InvalidOperationException("任务缺少消息引用,无法重新运行");
|
||||
|
||||
cfg.DelayMinMs = Math.Clamp(cfg.DelayMinMs, 0, 600000);
|
||||
cfg.DelayMaxMs = Math.Clamp(cfg.DelayMaxMs, cfg.DelayMinMs, 600000);
|
||||
cfg.MaxReports = Math.Max(0, cfg.MaxReports);
|
||||
cfg.CompletedAttempts = 0;
|
||||
cfg.FailedAttempts = 0;
|
||||
cfg.AccountCursor = 0;
|
||||
cfg.MessageCursor = 0;
|
||||
cfg.CommentCursor = 0;
|
||||
cfg.Canceled = false;
|
||||
cfg.Error = null;
|
||||
cfg.RecentFailures = new List<MessageReportTaskRuntimeFailure>();
|
||||
return cfg;
|
||||
}
|
||||
}
|
||||
@@ -1,419 +0,0 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using TelegramPanel.Core.Interfaces;
|
||||
using TelegramPanel.Core.Services;
|
||||
using TelegramPanel.Core.Services.Telegram;
|
||||
using TelegramPanel.Data.Entities;
|
||||
using TL;
|
||||
using WTelegram;
|
||||
|
||||
namespace TelegramPanel.Modules.MessageReport;
|
||||
|
||||
public sealed class MessageReportTaskTelegramService
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<string, string[]> ReportTypeKeywords = new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
[MessageReportTaskReportTypes.Spam] = ["spam", "垃圾", "广告"],
|
||||
[MessageReportTaskReportTypes.Violence] = ["violence", "violent", "暴力"],
|
||||
[MessageReportTaskReportTypes.Pornography] = ["porn", "pornography", "sexual", "nsfw", "色情", "成人"],
|
||||
[MessageReportTaskReportTypes.ChildAbuse] = ["child", "minor", "underage", "儿童", "未成年", "虐待", "剥削"],
|
||||
[MessageReportTaskReportTypes.Copyright] = ["copyright", "版权", "侵权", "盗版"],
|
||||
[MessageReportTaskReportTypes.PersonalDetails] = ["personal", "private", "privacy", "隐私", "个人信息", "泄露"],
|
||||
[MessageReportTaskReportTypes.IllegalDrugs] = ["drug", "drugs", "毒品", "违禁药"],
|
||||
[MessageReportTaskReportTypes.Fake] = ["fake", "fraud", "scam", "冒充", "诈骗", "虚假"],
|
||||
[MessageReportTaskReportTypes.Other] = ["other", "其他"]
|
||||
};
|
||||
|
||||
private readonly AccountManagementService _accountManagement;
|
||||
private readonly ITelegramClientPool _clientPool;
|
||||
private readonly AccountTelegramToolsService _accountTools;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly ILogger<MessageReportTaskTelegramService> _logger;
|
||||
|
||||
public MessageReportTaskTelegramService(
|
||||
AccountManagementService accountManagement,
|
||||
ITelegramClientPool clientPool,
|
||||
AccountTelegramToolsService accountTools,
|
||||
IConfiguration configuration,
|
||||
ILogger<MessageReportTaskTelegramService> logger)
|
||||
{
|
||||
_accountManagement = accountManagement;
|
||||
_clientPool = clientPool;
|
||||
_accountTools = accountTools;
|
||||
_configuration = configuration;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<(bool Success, string? Error, ResolvedMessageTarget? Target)> ResolveMessageReferenceAsync(
|
||||
int accountId,
|
||||
string rawReference,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!MessageReportTaskInputHelper.TryParseMessageReference(rawReference, out var parsed, out var parseError) || parsed == null)
|
||||
return (false, parseError ?? "消息引用无效", null);
|
||||
|
||||
var resolved = await _accountTools.ResolveChatTargetAsync(accountId, parsed.Target, cancellationToken);
|
||||
if (!resolved.Success || resolved.Target == null)
|
||||
return (false, NormalizeError(resolved.Error), null);
|
||||
|
||||
return (true, null, new ResolvedMessageTarget(parsed, resolved.Target));
|
||||
}
|
||||
|
||||
public async Task<ReportMessageResult> ReportMessageAsync(
|
||||
int accountId,
|
||||
ResolvedMessageTarget target,
|
||||
MessageReportTaskConfig config,
|
||||
int commentCursor,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var client = await GetOrCreateConnectedClientAsync(accountId, cancellationToken);
|
||||
var option = Array.Empty<byte>();
|
||||
var comment = string.Empty;
|
||||
string? selectedOptionText = null;
|
||||
var nextCommentCursor = commentCursor;
|
||||
|
||||
for (var step = 0; step < 6; step++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var result = await ExecuteTelegramRequestAsync(
|
||||
accountId,
|
||||
"举报消息",
|
||||
() => client.Messages_Report(target.ChatTarget.Peer, [target.Reference.MessageId], option, comment),
|
||||
cancellationToken,
|
||||
resetClientOnTimeout: true);
|
||||
|
||||
switch (result)
|
||||
{
|
||||
case ReportResultReported:
|
||||
return ReportMessageResult.Ok(nextCommentCursor, selectedOptionText);
|
||||
|
||||
case ReportResultChooseOption chooseOption:
|
||||
{
|
||||
var selected = SelectOption(chooseOption.options, config.ReportType, config.OptionKeywords, out var matchError);
|
||||
if (selected == null)
|
||||
return ReportMessageResult.Fail(matchError ?? "无法匹配 Telegram 返回的举报菜单", nextCommentCursor);
|
||||
|
||||
option = selected.option ?? Array.Empty<byte>();
|
||||
comment = string.Empty;
|
||||
selectedOptionText = selected.text;
|
||||
break;
|
||||
}
|
||||
|
||||
case ReportResultAddComment addComment:
|
||||
{
|
||||
option = addComment.option ?? option;
|
||||
var selectedComment = PickComment(config.Comments, config.CommentMode, nextCommentCursor, out nextCommentCursor);
|
||||
if (string.IsNullOrWhiteSpace(selectedComment))
|
||||
{
|
||||
if (!addComment.flags.HasFlag(ReportResultAddComment.Flags.optional))
|
||||
return ReportMessageResult.Fail("当前举报步骤要求填写举报说明,但任务配置未提供可用文案", nextCommentCursor);
|
||||
|
||||
comment = string.Empty;
|
||||
}
|
||||
else
|
||||
{
|
||||
comment = selectedComment;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
return ReportMessageResult.Fail($"收到未知举报结果类型:{result.GetType().Name}", nextCommentCursor);
|
||||
}
|
||||
}
|
||||
|
||||
return ReportMessageResult.Fail("举报流程超过允许的最大步骤数", commentCursor);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var mapped = AccountTelegramToolsService.MapTelegramException(ex);
|
||||
_logger.LogWarning(ex, "Message report failed for account {AccountId}, target {Target}", accountId, target.Reference.CanonicalId);
|
||||
return ReportMessageResult.Fail(string.IsNullOrWhiteSpace(mapped.details) ? mapped.summary : $"{mapped.summary}:{mapped.details}", commentCursor);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Client> GetOrCreateConnectedClientAsync(int accountId, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var existing = _clientPool.GetClient(accountId);
|
||||
if (existing?.User != null)
|
||||
return existing;
|
||||
|
||||
var account = await _accountManagement.GetAccountAsync(accountId)
|
||||
?? throw new InvalidOperationException($"账号不存在:{accountId}");
|
||||
|
||||
var apiId = ResolveApiId(account);
|
||||
var apiHash = ResolveApiHash(account);
|
||||
var sessionKey = ResolveSessionKey(account, apiHash);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(account.SessionPath))
|
||||
throw new InvalidOperationException("账号缺少 SessionPath,无法创建 Telegram 客户端");
|
||||
|
||||
var absoluteSessionPath = Path.GetFullPath(account.SessionPath);
|
||||
if (File.Exists(absoluteSessionPath) && SessionDataConverter.LooksLikeSqliteSession(absoluteSessionPath))
|
||||
{
|
||||
var converted = await SessionDataConverter.TryConvertSqliteSessionFromJsonAsync(
|
||||
phone: account.Phone,
|
||||
apiId: account.ApiId,
|
||||
apiHash: account.ApiHash,
|
||||
sqliteSessionPath: absoluteSessionPath,
|
||||
logger: _logger);
|
||||
|
||||
if (!converted.Ok)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"该账号的 Session 文件为 SQLite 格式:{account.SessionPath},无法自动转换为可用 session。原因:{converted.Reason}。"
|
||||
+ "建议:重新导入包含 session_string 的 json,或到【账号-手机号登录】重新登录生成新的 sessions/*.session。"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await _clientPool.RemoveClientAsync(accountId);
|
||||
|
||||
var client = await _clientPool.GetOrCreateClientAsync(
|
||||
accountId: accountId,
|
||||
apiId: apiId,
|
||||
apiHash: apiHash,
|
||||
sessionPath: account.SessionPath,
|
||||
sessionKey: sessionKey,
|
||||
phoneNumber: account.Phone,
|
||||
userId: account.UserId > 0 ? account.UserId : null);
|
||||
|
||||
try
|
||||
{
|
||||
await ExecuteTelegramRequestAsync(
|
||||
accountId,
|
||||
"连接 Telegram",
|
||||
() => client.ConnectAsync(),
|
||||
cancellationToken,
|
||||
resetClientOnTimeout: true);
|
||||
|
||||
if (client.User == null && (client.UserId != 0 || account.UserId != 0))
|
||||
{
|
||||
await ExecuteTelegramRequestAsync(
|
||||
accountId,
|
||||
"恢复 Telegram 登录状态",
|
||||
() => client.LoginUserIfNeeded(reloginOnFailedResume: false),
|
||||
cancellationToken,
|
||||
resetClientOnTimeout: true);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (LooksLikeSessionApiMismatchOrCorrupted(ex))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"该账号的 Session 文件无法解析(通常是 ApiId/ApiHash 与生成 session 时不一致,或 session 文件已损坏)。"
|
||||
+ "请到【账号-手机号登录】重新登录生成新的 session 后再试。",
|
||||
ex);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Telegram 会话加载失败:{ex.Message}", ex);
|
||||
}
|
||||
|
||||
if (client.User == null)
|
||||
throw new InvalidOperationException("账号未登录或 session 已失效,请重新登录生成新的 session");
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
private async Task ExecuteTelegramRequestAsync(
|
||||
int accountId,
|
||||
string operation,
|
||||
Func<Task> action,
|
||||
CancellationToken cancellationToken,
|
||||
bool resetClientOnTimeout = false)
|
||||
{
|
||||
try
|
||||
{
|
||||
await action().WaitAsync(GetTelegramRequestTimeout(), cancellationToken);
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
if (resetClientOnTimeout)
|
||||
await _clientPool.RemoveClientAsync(accountId);
|
||||
|
||||
throw new TimeoutException($"{operation} 超时");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<T> ExecuteTelegramRequestAsync<T>(
|
||||
int accountId,
|
||||
string operation,
|
||||
Func<Task<T>> action,
|
||||
CancellationToken cancellationToken,
|
||||
bool resetClientOnTimeout = false)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await action().WaitAsync(GetTelegramRequestTimeout(), cancellationToken);
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
if (resetClientOnTimeout)
|
||||
await _clientPool.RemoveClientAsync(accountId);
|
||||
|
||||
throw new TimeoutException($"{operation} 超时");
|
||||
}
|
||||
}
|
||||
|
||||
private TimeSpan GetTelegramRequestTimeout()
|
||||
{
|
||||
if (int.TryParse(_configuration["Telegram:RequestTimeoutSeconds"], out var seconds) && seconds >= 10 && seconds <= 600)
|
||||
return TimeSpan.FromSeconds(seconds);
|
||||
|
||||
return TimeSpan.FromSeconds(45);
|
||||
}
|
||||
|
||||
private static MessageReportOption? SelectOption(
|
||||
IEnumerable<MessageReportOption>? options,
|
||||
string reportType,
|
||||
IEnumerable<string>? customKeywords,
|
||||
out string? error)
|
||||
{
|
||||
error = null;
|
||||
var optionList = (options ?? Array.Empty<MessageReportOption>()).ToList();
|
||||
if (optionList.Count == 0)
|
||||
{
|
||||
error = "Telegram 未返回可用的举报选项";
|
||||
return null;
|
||||
}
|
||||
|
||||
var keywords = BuildKeywords(reportType, customKeywords).ToList();
|
||||
if (keywords.Count > 0)
|
||||
{
|
||||
foreach (var candidate in optionList)
|
||||
{
|
||||
var normalizedText = NormalizeForMatch(candidate.text);
|
||||
if (keywords.Any(keyword => normalizedText.Contains(keyword, StringComparison.Ordinal)))
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
if (string.Equals(reportType, MessageReportTaskReportTypes.Auto, StringComparison.OrdinalIgnoreCase))
|
||||
return optionList[0];
|
||||
|
||||
if (optionList.Count == 1)
|
||||
return optionList[0];
|
||||
|
||||
error = "未能根据当前配置匹配 Telegram 返回的举报选项:"
|
||||
+ string.Join(" / ", optionList.Select(x => string.IsNullOrWhiteSpace(x.text) ? "<空标题>" : x.text.Trim()));
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IEnumerable<string> BuildKeywords(string reportType, IEnumerable<string>? customKeywords)
|
||||
{
|
||||
var normalizedReportType = MessageReportTaskReportTypes.Normalize(reportType);
|
||||
if (ReportTypeKeywords.TryGetValue(normalizedReportType, out var defaultKeywords))
|
||||
{
|
||||
foreach (var keyword in defaultKeywords)
|
||||
{
|
||||
var normalized = NormalizeForMatch(keyword);
|
||||
if (normalized.Length > 0)
|
||||
yield return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var keyword in MessageReportTaskInputHelper.NormalizeLines(customKeywords, distinct: true))
|
||||
{
|
||||
var normalized = NormalizeForMatch(keyword);
|
||||
if (normalized.Length > 0)
|
||||
yield return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeForMatch(string? value)
|
||||
{
|
||||
return new string((value ?? string.Empty)
|
||||
.Trim()
|
||||
.ToLowerInvariant()
|
||||
.Where(ch => !char.IsWhiteSpace(ch) && ch is not '-' and not '_' and not ':' and not ':')
|
||||
.ToArray());
|
||||
}
|
||||
|
||||
private static string? PickComment(IReadOnlyList<string>? comments, string mode, int cursor, out int nextCursor)
|
||||
{
|
||||
var normalized = MessageReportTaskInputHelper.NormalizeLines(comments, distinct: false);
|
||||
if (normalized.Count == 0)
|
||||
{
|
||||
nextCursor = cursor;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (string.Equals(MessageReportTaskModes.Normalize(mode), MessageReportTaskModes.Queue, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var index = NormalizeCursor(cursor, normalized.Count);
|
||||
nextCursor = normalized.Count == 0 ? 0 : (index + 1) % normalized.Count;
|
||||
return normalized[index];
|
||||
}
|
||||
|
||||
nextCursor = cursor;
|
||||
return normalized[Random.Shared.Next(normalized.Count)];
|
||||
}
|
||||
|
||||
private static int NormalizeCursor(int cursor, int count)
|
||||
{
|
||||
if (count <= 0)
|
||||
return 0;
|
||||
|
||||
if (cursor < 0)
|
||||
return 0;
|
||||
|
||||
return cursor % count;
|
||||
}
|
||||
|
||||
private static string NormalizeError(string? error)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(error) ? "Telegram 请求失败" : error.Trim();
|
||||
}
|
||||
|
||||
private int ResolveApiId(Account account)
|
||||
{
|
||||
if (int.TryParse(_configuration["Telegram:ApiId"], out var globalApiId) && globalApiId > 0)
|
||||
return globalApiId;
|
||||
if (account.ApiId > 0)
|
||||
return account.ApiId;
|
||||
throw new InvalidOperationException("未配置全局 ApiId,且账号缺少 ApiId");
|
||||
}
|
||||
|
||||
private string ResolveApiHash(Account account)
|
||||
{
|
||||
var global = _configuration["Telegram:ApiHash"];
|
||||
if (!string.IsNullOrWhiteSpace(global))
|
||||
return global.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(account.ApiHash))
|
||||
return account.ApiHash.Trim();
|
||||
throw new InvalidOperationException("未配置全局 ApiHash,且账号缺少 ApiHash");
|
||||
}
|
||||
|
||||
private static string ResolveSessionKey(Account account, string apiHash)
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(account.ApiHash) ? account.ApiHash.Trim() : apiHash.Trim();
|
||||
}
|
||||
|
||||
private static bool LooksLikeSessionApiMismatchOrCorrupted(Exception ex)
|
||||
{
|
||||
var message = ex.Message ?? string.Empty;
|
||||
return message.Contains("Can't read session block", StringComparison.OrdinalIgnoreCase)
|
||||
|| message.Contains("Use the correct api_hash", StringComparison.OrdinalIgnoreCase)
|
||||
|| message.Contains("Use the correct api_hash/id/key", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record ResolvedMessageTarget(
|
||||
ParsedMessageReference Reference,
|
||||
AccountTelegramToolsService.ResolvedChatTarget ChatTarget);
|
||||
|
||||
public sealed record ReportMessageResult(bool Success, string? Error, int NextCommentCursor, string? SelectedOptionText)
|
||||
{
|
||||
public static ReportMessageResult Ok(int nextCommentCursor, string? selectedOptionText)
|
||||
=> new(true, null, nextCommentCursor, selectedOptionText);
|
||||
|
||||
public static ReportMessageResult Fail(string error, int nextCommentCursor)
|
||||
=> new(false, error, nextCommentCursor, null);
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Razor">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<AssemblyName>TelegramPanel.Modules.MessageReport</AssemblyName>
|
||||
<RootNamespace>TelegramPanel.Modules.MessageReport</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\TelegramPanel.Modules.Abstractions\TelegramPanel.Modules.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\TelegramPanel.Core\TelegramPanel.Core.csproj" />
|
||||
<ProjectReference Include="..\TelegramPanel.Data\TelegramPanel.Data.csproj" />
|
||||
<PackageReference Include="MudBlazor" Version="7.*" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Remove="publish/**" />
|
||||
<None Remove="publish/**" />
|
||||
<Content Update="manifest.json" CopyToOutputDirectory="Never" CopyToPublishDirectory="Never" />
|
||||
<None Update="manifest.json" CopyToOutputDirectory="Never" CopyToPublishDirectory="Never" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -1,4 +0,0 @@
|
||||
@using Microsoft.AspNetCore.Components
|
||||
@using MudBlazor
|
||||
@using TelegramPanel.Modules
|
||||
@using TelegramPanel.Modules.MessageReport
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"id": "task.message-report",
|
||||
"name": "任务:消息举报",
|
||||
"version": "1.0.0",
|
||||
"host": { "min": "0.0.0", "max": "2.0.0" },
|
||||
"dependencies": [],
|
||||
"entry": {
|
||||
"assembly": "TelegramPanel.Modules.MessageReport.dll",
|
||||
"type": "TelegramPanel.Modules.MessageReport.MessageReportTaskModule"
|
||||
}
|
||||
}
|
||||
-315
@@ -1,315 +0,0 @@
|
||||
@using System.Text.Json
|
||||
@using TelegramPanel.Modules.MessageReportTask.Services
|
||||
@inject AccountCategoryManagementService CategoryManagement
|
||||
|
||||
<MudStack Spacing="2">
|
||||
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Dense="true">
|
||||
该任务会按账号分类轮流对指定消息链接执行举报。最多举报次数为 0 表示不设上限,直到所有可用账号-消息组合处理完成或你在任务中心手动停止。
|
||||
</MudAlert>
|
||||
|
||||
<MudAlert Severity="Severity.Warning" Variant="Variant.Outlined" Dense="true">
|
||||
Telegram 的消息举报是动态选项流程。这里的“举报类型”本质上是匹配 Telegram 返回的选项文字;如果匹配不到,任务会记失败,但不会盲选错误类型。
|
||||
</MudAlert>
|
||||
|
||||
@if (loading)
|
||||
{
|
||||
<MudProgressCircular Indeterminate="true" Color="Color.Primary" Size="Size.Small" />
|
||||
<MudText Typo="Typo.caption">正在加载账号分类...</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudSelect T="int"
|
||||
Label="执行账号分类(可多选)"
|
||||
Variant="Variant.Outlined"
|
||||
Dense="true"
|
||||
MultiSelection="true"
|
||||
SelectedValues="selectedCategoryIds"
|
||||
SelectedValuesChanged="OnSelectedCategoriesChanged"
|
||||
HelperText="任务会从所有选中分类中抽取可用账号依次执行举报。">
|
||||
@foreach (var c in categories)
|
||||
{
|
||||
<MudSelectItem Value="@c.Id">@c.Name</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
|
||||
<MudTextField @bind-Value="messageLinksText" @bind-Value:after="OnInputsChanged"
|
||||
Label="消息链接(每行一个)"
|
||||
Variant="Variant.Outlined"
|
||||
Lines="6"
|
||||
Immediate="true"
|
||||
HelperText="支持:https://t.me/username/123、https://t.me/c/123456/789、tg://privatepost?channel=123456&post=789、username/123、-1001234567890/123" />
|
||||
|
||||
<MudGrid>
|
||||
<MudItem xs="12" md="4">
|
||||
<MudNumericField T="int" @bind-Value="delayMinSeconds" @bind-Value:after="OnInputsChanged"
|
||||
Label="最小间隔(秒)" Variant="Variant.Outlined" Min="0" Max="86400"
|
||||
HelperText="与最大间隔相同即固定间隔。" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" md="4">
|
||||
<MudNumericField T="int" @bind-Value="delayMaxSeconds" @bind-Value:after="OnInputsChanged"
|
||||
Label="最大间隔(秒)" Variant="Variant.Outlined" Min="0" Max="86400" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" md="4">
|
||||
<MudNumericField T="int" @bind-Value="maxReports" @bind-Value:after="OnInputsChanged"
|
||||
Label="最多举报次数" Variant="Variant.Outlined" Min="0" Max="1000000"
|
||||
HelperText="0 表示不限制次数上限。" />
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
|
||||
<MudSelect T="string" @bind-Value="reportPreset" @bind-Value:after="OnInputsChanged"
|
||||
Label="举报类型预设" Variant="Variant.Outlined" Dense="true"
|
||||
HelperText="预设会转成关键词去匹配 Telegram 返回的举报选项。">
|
||||
<MudSelectItem Value="@MessageReportTaskPresets.Spam">垃圾 / 骚扰</MudSelectItem>
|
||||
<MudSelectItem Value="@MessageReportTaskPresets.Violence">暴力 / 威胁</MudSelectItem>
|
||||
<MudSelectItem Value="@MessageReportTaskPresets.Pornography">色情 / 淫秽</MudSelectItem>
|
||||
<MudSelectItem Value="@MessageReportTaskPresets.ChildAbuse">儿童虐待</MudSelectItem>
|
||||
<MudSelectItem Value="@MessageReportTaskPresets.Copyright">版权侵权</MudSelectItem>
|
||||
<MudSelectItem Value="@MessageReportTaskPresets.IllegalDrugs">违禁药物</MudSelectItem>
|
||||
<MudSelectItem Value="@MessageReportTaskPresets.PersonalDetails">隐私 / 个人信息</MudSelectItem>
|
||||
<MudSelectItem Value="@MessageReportTaskPresets.Other">其他</MudSelectItem>
|
||||
<MudSelectItem Value="@MessageReportTaskPresets.FirstAvailable">直接选第一个选项</MudSelectItem>
|
||||
<MudSelectItem Value="@MessageReportTaskPresets.Custom">自定义关键词</MudSelectItem>
|
||||
</MudSelect>
|
||||
|
||||
<MudTextField @bind-Value="customKeywordsText" @bind-Value:after="OnInputsChanged"
|
||||
Label="自定义选项关键词(可选,每行一个)"
|
||||
Variant="Variant.Outlined"
|
||||
Lines="4"
|
||||
Immediate="true"
|
||||
HelperText="优先于预设关键词。适合 Telegram 账号语言与预设不一致时手动微调。" />
|
||||
|
||||
<MudTextField @bind-Value="commentText" @bind-Value:after="OnInputsChanged"
|
||||
Label="举报文案(可选)"
|
||||
Variant="Variant.Outlined"
|
||||
Lines="4"
|
||||
Immediate="true"
|
||||
HelperText="当 Telegram 要求补充说明时会自动提交。支持变量:{消息链接}、{聊天标题}、{消息ID}" />
|
||||
}
|
||||
</MudStack>
|
||||
|
||||
@code {
|
||||
[Parameter] public ModuleTaskDraft Draft { get; set; }
|
||||
[Parameter] public EventCallback<ModuleTaskDraft> DraftChanged { get; set; }
|
||||
[Parameter] public string? InitialConfigJson { get; set; }
|
||||
|
||||
private List<AccountCategory> categories = new();
|
||||
private bool loading = true;
|
||||
private bool initializedFromConfig;
|
||||
|
||||
private IEnumerable<int> selectedCategoryIds = Array.Empty<int>();
|
||||
private string messageLinksText = string.Empty;
|
||||
private int delayMinSeconds = 15;
|
||||
private int delayMaxSeconds = 45;
|
||||
private int maxReports;
|
||||
private string reportPreset = MessageReportTaskPresets.Spam;
|
||||
private string customKeywordsText = string.Empty;
|
||||
private string commentText = string.Empty;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
ApplyInitialConfigIfNeeded();
|
||||
|
||||
try
|
||||
{
|
||||
categories = (await CategoryManagement.GetAllCategoriesAsync())
|
||||
.Where(x => !x.ExcludeFromOperations)
|
||||
.OrderBy(x => x.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
if (!selectedCategoryIds.Any())
|
||||
{
|
||||
var firstCategoryId = categories.FirstOrDefault()?.Id ?? 0;
|
||||
selectedCategoryIds = firstCategoryId > 0 ? new[] { firstCategoryId } : Array.Empty<int>();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
loading = false;
|
||||
await PushDraftAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyInitialConfigIfNeeded()
|
||||
{
|
||||
if (initializedFromConfig)
|
||||
return;
|
||||
|
||||
initializedFromConfig = true;
|
||||
|
||||
var raw = (InitialConfigJson ?? string.Empty).Trim();
|
||||
if (raw.Length == 0)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
var cfg = JsonSerializer.Deserialize<MessageReportTaskConfig>(raw);
|
||||
if (cfg == null)
|
||||
return;
|
||||
|
||||
var categoryIds = NormalizeCategoryIds(cfg.CategoryIds, cfg.CategoryId);
|
||||
if (categoryIds.Count > 0)
|
||||
selectedCategoryIds = categoryIds;
|
||||
|
||||
messageLinksText = string.Join(Environment.NewLine, cfg.MessageLinks ?? new List<string>());
|
||||
delayMinSeconds = ConvertMillisecondsToSeconds(cfg.DelayMinMs);
|
||||
delayMaxSeconds = ConvertMillisecondsToSeconds(cfg.DelayMaxMs);
|
||||
maxReports = Math.Max(0, cfg.MaxReports);
|
||||
reportPreset = NormalizePreset(cfg.ReportPreset);
|
||||
customKeywordsText = string.Join(Environment.NewLine, cfg.OptionKeywords ?? new List<string>());
|
||||
commentText = (cfg.Comment ?? string.Empty).Trim();
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OnSelectedCategoriesChanged(IEnumerable<int> values)
|
||||
{
|
||||
selectedCategoryIds = NormalizeCategoryIds(values, 0);
|
||||
await OnInputsChanged();
|
||||
}
|
||||
|
||||
private async Task OnInputsChanged()
|
||||
{
|
||||
await PushDraftAsync();
|
||||
}
|
||||
|
||||
private async Task PushDraftAsync()
|
||||
{
|
||||
var links = ParseLines(messageLinksText, distinct: true);
|
||||
var selectedIds = NormalizeCategoryIds(selectedCategoryIds, 0);
|
||||
var selectedCategories = categories.Where(x => selectedIds.Contains(x.Id)).ToList();
|
||||
var keywords = ParseLines(customKeywordsText, distinct: true);
|
||||
var normalizedPreset = NormalizePreset(reportPreset);
|
||||
var comment = NormalizeNullableText(commentText);
|
||||
|
||||
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 (selectedCategories.Count == 0)
|
||||
{
|
||||
await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "请至少选择一个执行账号分类"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (links.Count == 0)
|
||||
{
|
||||
await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "请至少填写一条消息链接"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (delayMinSeconds < 0 || delayMaxSeconds < 0)
|
||||
{
|
||||
await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "间隔不能为负数"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (delayMaxSeconds < delayMinSeconds)
|
||||
{
|
||||
await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "最大间隔不能小于最小间隔"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (maxReports < 0)
|
||||
{
|
||||
await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "最多举报次数不能小于 0"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (normalizedPreset == MessageReportTaskPresets.Custom && keywords.Count == 0)
|
||||
{
|
||||
await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "选择“自定义关键词”时,至少填写一个关键词"));
|
||||
return;
|
||||
}
|
||||
|
||||
var categoryNames = selectedCategories.Select(x => x.Name).ToList();
|
||||
var cfg = new MessageReportTaskConfig
|
||||
{
|
||||
CategoryId = selectedCategories[0].Id,
|
||||
CategoryName = selectedCategories[0].Name,
|
||||
CategoryIds = selectedCategories.Select(x => x.Id).ToList(),
|
||||
CategoryNames = categoryNames,
|
||||
MessageLinks = links,
|
||||
DelayMinMs = delayMinSeconds * 1000,
|
||||
DelayMaxMs = delayMaxSeconds * 1000,
|
||||
MaxReports = maxReports,
|
||||
ReportPreset = normalizedPreset,
|
||||
OptionKeywords = keywords,
|
||||
Comment = comment
|
||||
};
|
||||
|
||||
var total = maxReports > 0 ? maxReports : 0;
|
||||
var json = JsonSerializer.Serialize(cfg, new JsonSerializerOptions { WriteIndented = true });
|
||||
await DraftChanged.InvokeAsync(new ModuleTaskDraft(total, json, true, null));
|
||||
}
|
||||
|
||||
private static List<int> NormalizeCategoryIds(IEnumerable<int>? values, int fallback)
|
||||
{
|
||||
var ids = (values ?? Array.Empty<int>())
|
||||
.Where(x => x > 0)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
if (ids.Count == 0 && fallback > 0)
|
||||
ids.Add(fallback);
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
private static int ConvertMillisecondsToSeconds(int value)
|
||||
{
|
||||
if (value <= 0)
|
||||
return 0;
|
||||
|
||||
return Math.Max(1, (int)Math.Ceiling(value / 1000d));
|
||||
}
|
||||
|
||||
private static string NormalizePreset(string? value)
|
||||
{
|
||||
var preset = (value ?? string.Empty).Trim().ToLowerInvariant();
|
||||
return preset switch
|
||||
{
|
||||
MessageReportTaskPresets.Spam => MessageReportTaskPresets.Spam,
|
||||
MessageReportTaskPresets.Violence => MessageReportTaskPresets.Violence,
|
||||
MessageReportTaskPresets.Pornography => MessageReportTaskPresets.Pornography,
|
||||
MessageReportTaskPresets.ChildAbuse => MessageReportTaskPresets.ChildAbuse,
|
||||
MessageReportTaskPresets.Copyright => MessageReportTaskPresets.Copyright,
|
||||
MessageReportTaskPresets.IllegalDrugs => MessageReportTaskPresets.IllegalDrugs,
|
||||
MessageReportTaskPresets.PersonalDetails => MessageReportTaskPresets.PersonalDetails,
|
||||
MessageReportTaskPresets.Other => MessageReportTaskPresets.Other,
|
||||
MessageReportTaskPresets.FirstAvailable => MessageReportTaskPresets.FirstAvailable,
|
||||
MessageReportTaskPresets.Custom => MessageReportTaskPresets.Custom,
|
||||
_ => MessageReportTaskPresets.Spam
|
||||
};
|
||||
}
|
||||
|
||||
private static List<string> ParseLines(string? text, bool distinct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
return new List<string>();
|
||||
|
||||
var values = text
|
||||
.Split(new[] { "\r\n", "\n", "\r" }, StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(x => (x ?? string.Empty).Trim())
|
||||
.Where(x => x.Length > 0);
|
||||
|
||||
return distinct
|
||||
? values.Distinct(StringComparer.OrdinalIgnoreCase).ToList()
|
||||
: values.ToList();
|
||||
}
|
||||
|
||||
private static string? NormalizeNullableText(string? value)
|
||||
{
|
||||
var text = (value ?? string.Empty).Trim();
|
||||
return text.Length == 0 ? null : text;
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using MudBlazor;
|
||||
using TelegramPanel.Modules;
|
||||
using TelegramPanel.Modules.MessageReportTask.Components.Dialogs;
|
||||
using TelegramPanel.Modules.MessageReportTask.Services;
|
||||
|
||||
namespace TelegramPanel.Modules.MessageReportTask;
|
||||
|
||||
public sealed class MessageReportTaskModule : ITelegramPanelModule, IModuleTaskProvider
|
||||
{
|
||||
public ModuleManifest Manifest { get; } = new()
|
||||
{
|
||||
Id = MessageReportTaskConstants.ModuleId,
|
||||
Name = "任务:消息举报",
|
||||
Version = "1.0.0",
|
||||
Host = new HostCompatibility { Min = "1.31.6", Max = "2.0.0" },
|
||||
Entry = new ModuleEntryPoint
|
||||
{
|
||||
Assembly = "TelegramPanel.Modules.MessageReportTask.dll",
|
||||
Type = typeof(MessageReportTaskModule).FullName!
|
||||
}
|
||||
};
|
||||
|
||||
public void ConfigureServices(IServiceCollection services, ModuleHostContext context)
|
||||
{
|
||||
services.AddScoped<TelegramMessageReportService>();
|
||||
services.AddScoped<IModuleTaskHandler, MessageReportTaskHandler>();
|
||||
services.AddSingleton<IModuleTaskRerunBuilder, MessageReportTaskRerunBuilder>();
|
||||
}
|
||||
|
||||
public void MapEndpoints(IEndpointRouteBuilder endpoints, ModuleHostContext context)
|
||||
{
|
||||
}
|
||||
|
||||
public IEnumerable<ModuleTaskDefinition> GetTasks(ModuleHostContext context)
|
||||
{
|
||||
yield return new ModuleTaskDefinition
|
||||
{
|
||||
Category = "user",
|
||||
TaskType = MessageReportTaskConstants.TaskType,
|
||||
DisplayName = "消息举报(多账号)",
|
||||
Description = "按账号分类对指定消息链接执行举报,支持间隔、Cron 计划和数量上限。",
|
||||
Icon = Icons.Material.Filled.Flag,
|
||||
EditorComponentType = typeof(MessageReportTaskEditor).AssemblyQualifiedName ?? string.Empty,
|
||||
TaskCenter = new ModuleTaskCenterCapabilities
|
||||
{
|
||||
CanPause = true,
|
||||
CanResume = true,
|
||||
CanEdit = true,
|
||||
CanRerun = true,
|
||||
EditComponentType = typeof(MessageReportTaskEditor).AssemblyQualifiedName ?? string.Empty,
|
||||
AutoPauseBeforeEdit = true
|
||||
},
|
||||
Order = 150
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,429 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using TelegramPanel.Core.Services;
|
||||
using TelegramPanel.Data.Entities;
|
||||
using TelegramPanel.Modules;
|
||||
|
||||
namespace TelegramPanel.Modules.MessageReportTask.Services;
|
||||
|
||||
public sealed class MessageReportTaskHandler : IModuleTaskHandler
|
||||
{
|
||||
private const int MaxFailureLines = 50;
|
||||
|
||||
public string TaskType => MessageReportTaskConstants.TaskType;
|
||||
|
||||
public async Task ExecuteAsync(IModuleTaskExecutionHost host, CancellationToken cancellationToken)
|
||||
{
|
||||
var logger = host.Services.GetRequiredService<ILogger<MessageReportTaskHandler>>();
|
||||
var taskManagement = host.Services.GetRequiredService<BatchTaskManagementService>();
|
||||
var accountManagement = host.Services.GetRequiredService<AccountManagementService>();
|
||||
var reportService = host.Services.GetRequiredService<TelegramMessageReportService>();
|
||||
|
||||
var config = DeserializeConfig(host.Config);
|
||||
ValidateAndNormalizeConfig(config);
|
||||
|
||||
var selectedCategoryIds = NormalizeSelectedCategoryIds(config).ToHashSet();
|
||||
var allAccounts = (await accountManagement.GetAllAccountsAsync())
|
||||
.Where(x => x.IsActive && x.UserId > 0 && x.Category?.ExcludeFromOperations != true)
|
||||
.Where(x => x.CategoryId.HasValue && selectedCategoryIds.Contains(x.CategoryId.Value))
|
||||
.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 rawLink in config.MessageLinks)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (!await host.IsStillRunningAsync(cancellationToken))
|
||||
{
|
||||
config.Canceled = true;
|
||||
await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(config));
|
||||
return;
|
||||
}
|
||||
|
||||
var resolved = await reportService.ResolveMessageTargetAsync(account.Id, rawLink, cancellationToken);
|
||||
if (resolved.Success && resolved.Target != null)
|
||||
{
|
||||
slot.Targets.Add(new TargetSlot(resolved.Target));
|
||||
continue;
|
||||
}
|
||||
|
||||
AddFailure(config, account, rawLink, 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);
|
||||
}
|
||||
|
||||
config.Error = null;
|
||||
await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(config));
|
||||
|
||||
var completed = 0;
|
||||
var failed = 0;
|
||||
var accountQueueIndex = 0;
|
||||
var targetQueueIndexByAccountId = new Dictionary<int, int>();
|
||||
var lastProgressPersistAt = DateTime.UtcNow;
|
||||
|
||||
try
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested && accountSlots.Count > 0)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (!await host.IsStillRunningAsync(cancellationToken))
|
||||
{
|
||||
config.Canceled = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if (config.MaxReports > 0 && completed >= config.MaxReports)
|
||||
break;
|
||||
|
||||
var accountIdx = SelectNextAccountIndex(accountSlots, ref accountQueueIndex);
|
||||
if (accountIdx < 0)
|
||||
break;
|
||||
|
||||
var accountSlot = accountSlots[accountIdx];
|
||||
var targetIdx = SelectNextTargetIndex(accountSlot, targetQueueIndexByAccountId);
|
||||
if (targetIdx < 0)
|
||||
{
|
||||
accountSlots.RemoveAt(accountIdx);
|
||||
targetQueueIndexByAccountId.Remove(accountSlot.Account.Id);
|
||||
continue;
|
||||
}
|
||||
|
||||
var targetSlot = accountSlot.Targets[targetIdx];
|
||||
var result = await reportService.ReportMessageAsync(accountSlot.Account.Id, targetSlot.Target, config, cancellationToken);
|
||||
|
||||
completed++;
|
||||
var hadFailureThisRound = false;
|
||||
var shouldRemoveTarget = result.Success || result.ShouldRemoveTarget;
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
failed++;
|
||||
hadFailureThisRound = true;
|
||||
targetSlot.ConsecutiveFailures++;
|
||||
AddFailure(config, accountSlot.Account, targetSlot.Target.RawInput, NormalizeReason(result.Error));
|
||||
if (targetSlot.ConsecutiveFailures >= 3)
|
||||
shouldRemoveTarget = true;
|
||||
|
||||
await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(config));
|
||||
}
|
||||
|
||||
if (shouldRemoveTarget)
|
||||
{
|
||||
accountSlot.Targets.RemoveAt(targetIdx);
|
||||
if (accountSlot.Targets.Count == 0)
|
||||
{
|
||||
accountSlots.RemoveAt(accountIdx);
|
||||
targetQueueIndexByAccountId.Remove(accountSlot.Account.Id);
|
||||
}
|
||||
}
|
||||
|
||||
if (ShouldPersistProgress(completed, hadFailureThisRound, lastProgressPersistAt))
|
||||
{
|
||||
await host.UpdateProgressAsync(completed, failed, cancellationToken);
|
||||
lastProgressPersistAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
if (config.MaxReports > 0 && completed >= config.MaxReports)
|
||||
break;
|
||||
|
||||
if (accountSlots.Count == 0)
|
||||
break;
|
||||
|
||||
var delayMs = NextDelayMilliseconds(config.DelayMinMs, config.DelayMaxMs);
|
||||
if (delayMs > 0)
|
||||
{
|
||||
var keepRunning = await DelayWithTaskCheckAsync(host, delayMs, cancellationToken);
|
||||
if (!keepRunning)
|
||||
{
|
||||
config.Canceled = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "MessageReport task failed (taskId={TaskId})", host.TaskId);
|
||||
config.Error = ex.Message;
|
||||
await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(config));
|
||||
throw;
|
||||
}
|
||||
|
||||
if (config.MaxReports > 0 && accountSlots.Count == 0 && completed < host.Total)
|
||||
await taskManagement.UpdateTaskDraftAsync(host.TaskId, completed, SerializeIndented(config));
|
||||
else
|
||||
await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(config));
|
||||
|
||||
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 MessageReportTaskConfig DeserializeConfig(string? rawConfig)
|
||||
{
|
||||
var raw = (rawConfig ?? string.Empty).Trim();
|
||||
if (raw.Length == 0)
|
||||
throw new InvalidOperationException("任务配置为空");
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<MessageReportTaskConfig>(raw)
|
||||
?? throw new InvalidOperationException("任务配置解析结果为空");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new InvalidOperationException($"任务配置 JSON 无效:{ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateAndNormalizeConfig(MessageReportTaskConfig config)
|
||||
{
|
||||
config.CategoryIds = NormalizeSelectedCategoryIds(config);
|
||||
if (config.CategoryIds.Count == 0)
|
||||
throw new InvalidOperationException("请至少选择一个执行账号分类");
|
||||
|
||||
config.CategoryId = config.CategoryIds[0];
|
||||
config.CategoryNames = NormalizeCategoryNames(config.CategoryNames, config.CategoryName);
|
||||
config.CategoryName = config.CategoryNames.FirstOrDefault() ?? config.CategoryName;
|
||||
|
||||
config.MessageLinks = (config.MessageLinks ?? new List<string>())
|
||||
.Select(x => (x ?? string.Empty).Trim())
|
||||
.Where(x => x.Length > 0)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
if (config.MessageLinks.Count == 0)
|
||||
throw new InvalidOperationException("请至少填写一条消息链接");
|
||||
|
||||
config.OptionKeywords = (config.OptionKeywords ?? new List<string>())
|
||||
.Select(x => (x ?? string.Empty).Trim())
|
||||
.Where(x => x.Length > 0)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
config.ReportPreset = NormalizePreset(config.ReportPreset);
|
||||
if (config.ReportPreset == MessageReportTaskPresets.Custom && config.OptionKeywords.Count == 0)
|
||||
throw new InvalidOperationException("选择“自定义关键词”时,至少填写一个关键词");
|
||||
|
||||
config.Comment = NormalizeNullableText(config.Comment);
|
||||
if (config.DelayMinMs < 0) config.DelayMinMs = 0;
|
||||
if (config.DelayMaxMs < 0) config.DelayMaxMs = 0;
|
||||
if (config.DelayMinMs > 86400000) config.DelayMinMs = 86400000;
|
||||
if (config.DelayMaxMs > 86400000) config.DelayMaxMs = 86400000;
|
||||
if (config.DelayMaxMs < config.DelayMinMs) config.DelayMaxMs = config.DelayMinMs;
|
||||
if (config.MaxReports < 0) config.MaxReports = 0;
|
||||
|
||||
config.Canceled = false;
|
||||
config.Error = NormalizeNullableText(config.Error);
|
||||
config.RecentFailures ??= new List<MessageReportTaskRuntimeFailure>();
|
||||
}
|
||||
|
||||
private static List<int> NormalizeSelectedCategoryIds(MessageReportTaskConfig config)
|
||||
{
|
||||
var ids = (config.CategoryIds ?? new List<int>())
|
||||
.Where(x => x > 0)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
if (ids.Count == 0 && config.CategoryId > 0)
|
||||
ids.Add(config.CategoryId);
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
private static List<string> NormalizeCategoryNames(IEnumerable<string>? values, string? fallback)
|
||||
{
|
||||
var names = (values ?? Array.Empty<string>())
|
||||
.Select(x => (x ?? string.Empty).Trim())
|
||||
.Where(x => x.Length > 0)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
var fallbackName = (fallback ?? string.Empty).Trim();
|
||||
if (names.Count == 0 && fallbackName.Length > 0)
|
||||
names.Add(fallbackName);
|
||||
|
||||
return names;
|
||||
}
|
||||
|
||||
private static string NormalizePreset(string? preset)
|
||||
{
|
||||
var value = (preset ?? string.Empty).Trim().ToLowerInvariant();
|
||||
return value switch
|
||||
{
|
||||
MessageReportTaskPresets.Spam => MessageReportTaskPresets.Spam,
|
||||
MessageReportTaskPresets.Violence => MessageReportTaskPresets.Violence,
|
||||
MessageReportTaskPresets.Pornography => MessageReportTaskPresets.Pornography,
|
||||
MessageReportTaskPresets.ChildAbuse => MessageReportTaskPresets.ChildAbuse,
|
||||
MessageReportTaskPresets.Copyright => MessageReportTaskPresets.Copyright,
|
||||
MessageReportTaskPresets.IllegalDrugs => MessageReportTaskPresets.IllegalDrugs,
|
||||
MessageReportTaskPresets.PersonalDetails => MessageReportTaskPresets.PersonalDetails,
|
||||
MessageReportTaskPresets.Other => MessageReportTaskPresets.Other,
|
||||
MessageReportTaskPresets.FirstAvailable => MessageReportTaskPresets.FirstAvailable,
|
||||
MessageReportTaskPresets.Custom => MessageReportTaskPresets.Custom,
|
||||
_ => MessageReportTaskPresets.Spam
|
||||
};
|
||||
}
|
||||
|
||||
private static string? NormalizeNullableText(string? value)
|
||||
{
|
||||
var text = (value ?? string.Empty).Trim();
|
||||
return text.Length == 0 ? null : text;
|
||||
}
|
||||
|
||||
private static int SelectNextAccountIndex(List<AccountSlot> slots, ref int queueIndex)
|
||||
{
|
||||
if (slots.Count == 0)
|
||||
return -1;
|
||||
|
||||
if (queueIndex < 0 || queueIndex >= slots.Count)
|
||||
queueIndex = 0;
|
||||
|
||||
var selected = queueIndex;
|
||||
queueIndex = (queueIndex + 1) % slots.Count;
|
||||
return selected;
|
||||
}
|
||||
|
||||
private static int SelectNextTargetIndex(AccountSlot slot, Dictionary<int, int> targetQueueIndexByAccountId)
|
||||
{
|
||||
if (slot.Targets.Count == 0)
|
||||
return -1;
|
||||
|
||||
if (!targetQueueIndexByAccountId.TryGetValue(slot.Account.Id, out var index))
|
||||
index = 0;
|
||||
|
||||
if (index < 0 || index >= slot.Targets.Count)
|
||||
index = 0;
|
||||
|
||||
var selected = index;
|
||||
targetQueueIndexByAccountId[slot.Account.Id] = (selected + 1) % slot.Targets.Count;
|
||||
return selected;
|
||||
}
|
||||
|
||||
private static int NextDelayMilliseconds(int min, int max)
|
||||
{
|
||||
if (min <= 0 && max <= 0)
|
||||
return 0;
|
||||
if (max <= min)
|
||||
return Math.Max(0, min);
|
||||
return Random.Shared.Next(min, max + 1);
|
||||
}
|
||||
|
||||
private static bool ShouldPersistProgress(int completed, bool hadFailureThisRound, DateTime lastPersistAtUtc)
|
||||
{
|
||||
if (completed <= 0)
|
||||
return hadFailureThisRound;
|
||||
|
||||
if (hadFailureThisRound)
|
||||
return true;
|
||||
|
||||
if (completed % 5 == 0)
|
||||
return true;
|
||||
|
||||
return (DateTime.UtcNow - lastPersistAtUtc) >= TimeSpan.FromSeconds(10);
|
||||
}
|
||||
|
||||
private static async Task<bool> DelayWithTaskCheckAsync(IModuleTaskExecutionHost host, int delayMs, CancellationToken cancellationToken)
|
||||
{
|
||||
if (delayMs <= 0)
|
||||
return true;
|
||||
|
||||
var remaining = delayMs;
|
||||
while (remaining > 0)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (!await host.IsStillRunningAsync(cancellationToken))
|
||||
return false;
|
||||
|
||||
var chunk = Math.Min(remaining, 1000);
|
||||
await Task.Delay(chunk, cancellationToken);
|
||||
remaining -= chunk;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void AddFailure(MessageReportTaskConfig config, Account account, string messageLink, string reason)
|
||||
{
|
||||
config.RecentFailures ??= new List<MessageReportTaskRuntimeFailure>();
|
||||
config.RecentFailures.Insert(0, new MessageReportTaskRuntimeFailure
|
||||
{
|
||||
TimeUtc = DateTime.UtcNow,
|
||||
AccountId = account.Id,
|
||||
Account = BuildAccountDisplayName(account),
|
||||
MessageLink = (messageLink ?? string.Empty).Trim(),
|
||||
Reason = reason
|
||||
});
|
||||
|
||||
if (config.RecentFailures.Count > MaxFailureLines)
|
||||
config.RecentFailures = config.RecentFailures.Take(MaxFailureLines).ToList();
|
||||
}
|
||||
|
||||
private static string BuildAccountDisplayName(Account account)
|
||||
{
|
||||
var username = string.IsNullOrWhiteSpace(account.Username) ? string.Empty : $"@{account.Username}";
|
||||
var nickname = string.IsNullOrWhiteSpace(account.Nickname) ? string.Empty : account.Nickname.Trim();
|
||||
return string.Join(" / ", new[] { account.DisplayPhone, username, nickname }.Where(x => !string.IsNullOrWhiteSpace(x)));
|
||||
}
|
||||
|
||||
private static string NormalizeReason(string? reason)
|
||||
{
|
||||
var text = (reason ?? string.Empty).Trim();
|
||||
return text.Length == 0 ? "未知错误" : text;
|
||||
}
|
||||
|
||||
private static string SerializeIndented(MessageReportTaskConfig config)
|
||||
=> 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(TelegramMessageReportService.ResolvedReportMessageTarget target)
|
||||
{
|
||||
Target = target;
|
||||
}
|
||||
|
||||
public TelegramMessageReportService.ResolvedReportMessageTarget Target { get; }
|
||||
public int ConsecutiveFailures { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace TelegramPanel.Modules.MessageReportTask.Services;
|
||||
|
||||
public static class MessageReportTaskConstants
|
||||
{
|
||||
public const string ModuleId = "task.message-report";
|
||||
public const string TaskType = "user_message_report";
|
||||
}
|
||||
|
||||
public static class MessageReportTaskPresets
|
||||
{
|
||||
public const string Spam = "spam";
|
||||
public const string Violence = "violence";
|
||||
public const string Pornography = "pornography";
|
||||
public const string ChildAbuse = "child_abuse";
|
||||
public const string Copyright = "copyright";
|
||||
public const string IllegalDrugs = "illegal_drugs";
|
||||
public const string PersonalDetails = "personal_details";
|
||||
public const string Other = "other";
|
||||
public const string FirstAvailable = "first_available";
|
||||
public const string Custom = "custom";
|
||||
}
|
||||
|
||||
public sealed class MessageReportTaskConfig
|
||||
{
|
||||
[JsonPropertyName("category_id")]
|
||||
public int CategoryId { get; set; }
|
||||
|
||||
[JsonPropertyName("category_name")]
|
||||
public string? CategoryName { get; set; }
|
||||
|
||||
[JsonPropertyName("category_ids")]
|
||||
public List<int> CategoryIds { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("category_names")]
|
||||
public List<string> CategoryNames { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("message_links")]
|
||||
public List<string> MessageLinks { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("delay_min_ms")]
|
||||
public int DelayMinMs { get; set; } = 15000;
|
||||
|
||||
[JsonPropertyName("delay_max_ms")]
|
||||
public int DelayMaxMs { get; set; } = 45000;
|
||||
|
||||
[JsonPropertyName("max_reports")]
|
||||
public int MaxReports { get; set; }
|
||||
|
||||
[JsonPropertyName("report_preset")]
|
||||
public string ReportPreset { get; set; } = MessageReportTaskPresets.Spam;
|
||||
|
||||
[JsonPropertyName("option_keywords")]
|
||||
public List<string> OptionKeywords { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("comment")]
|
||||
public string? Comment { get; set; }
|
||||
|
||||
[JsonPropertyName("canceled")]
|
||||
public bool Canceled { get; set; }
|
||||
|
||||
[JsonPropertyName("error")]
|
||||
public string? Error { get; set; }
|
||||
|
||||
[JsonPropertyName("recent_failures")]
|
||||
public List<MessageReportTaskRuntimeFailure> RecentFailures { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class MessageReportTaskRuntimeFailure
|
||||
{
|
||||
[JsonPropertyName("time_utc")]
|
||||
public DateTime TimeUtc { get; set; }
|
||||
|
||||
[JsonPropertyName("account_id")]
|
||||
public int AccountId { get; set; }
|
||||
|
||||
[JsonPropertyName("account")]
|
||||
public string Account { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("message_link")]
|
||||
public string MessageLink { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("reason")]
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
}
|
||||
-132
@@ -1,132 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using TelegramPanel.Modules;
|
||||
|
||||
namespace TelegramPanel.Modules.MessageReportTask.Services;
|
||||
|
||||
public sealed class MessageReportTaskRerunBuilder : IModuleTaskRerunBuilder
|
||||
{
|
||||
public string TaskType => MessageReportTaskConstants.TaskType;
|
||||
|
||||
public ModuleTaskCreateRequest Build(ModuleTaskSnapshot task)
|
||||
{
|
||||
var rerunConfig = BuildRerunConfig(task.Config);
|
||||
var total = rerunConfig.MaxReports > 0 ? rerunConfig.MaxReports : 0;
|
||||
var configJson = JsonSerializer.Serialize(rerunConfig, new JsonSerializerOptions { WriteIndented = true });
|
||||
|
||||
return new ModuleTaskCreateRequest
|
||||
{
|
||||
TaskType = MessageReportTaskConstants.TaskType,
|
||||
Total = total,
|
||||
Config = configJson
|
||||
};
|
||||
}
|
||||
|
||||
private static MessageReportTaskConfig BuildRerunConfig(string? rawConfig)
|
||||
{
|
||||
var raw = (rawConfig ?? string.Empty).Trim();
|
||||
if (raw.Length == 0)
|
||||
throw new InvalidOperationException("任务配置为空,无法重新运行");
|
||||
|
||||
MessageReportTaskConfig cfg;
|
||||
try
|
||||
{
|
||||
cfg = JsonSerializer.Deserialize<MessageReportTaskConfig>(raw)
|
||||
?? throw new InvalidOperationException("任务配置解析结果为空");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new InvalidOperationException($"任务配置 JSON 无效:{ex.Message}");
|
||||
}
|
||||
|
||||
cfg.CategoryIds = NormalizeCategoryIds(cfg.CategoryIds, cfg.CategoryId);
|
||||
if (cfg.CategoryIds.Count == 0)
|
||||
throw new InvalidOperationException("任务缺少账号分类,无法重新运行");
|
||||
|
||||
cfg.CategoryId = cfg.CategoryIds[0];
|
||||
cfg.CategoryNames = NormalizeCategoryNames(cfg.CategoryNames, cfg.CategoryName);
|
||||
cfg.CategoryName = cfg.CategoryNames.FirstOrDefault() ?? cfg.CategoryName;
|
||||
|
||||
cfg.MessageLinks = (cfg.MessageLinks ?? new List<string>())
|
||||
.Select(x => (x ?? string.Empty).Trim())
|
||||
.Where(x => x.Length > 0)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
if (cfg.MessageLinks.Count == 0)
|
||||
throw new InvalidOperationException("任务缺少消息链接,无法重新运行");
|
||||
|
||||
cfg.OptionKeywords = (cfg.OptionKeywords ?? new List<string>())
|
||||
.Select(x => (x ?? string.Empty).Trim())
|
||||
.Where(x => x.Length > 0)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
if (cfg.DelayMinMs < 0) cfg.DelayMinMs = 0;
|
||||
if (cfg.DelayMaxMs < 0) cfg.DelayMaxMs = 0;
|
||||
if (cfg.DelayMinMs > 86400000) cfg.DelayMinMs = 86400000;
|
||||
if (cfg.DelayMaxMs > 86400000) cfg.DelayMaxMs = 86400000;
|
||||
if (cfg.DelayMaxMs < cfg.DelayMinMs) cfg.DelayMaxMs = cfg.DelayMinMs;
|
||||
if (cfg.MaxReports < 0) cfg.MaxReports = 0;
|
||||
|
||||
cfg.ReportPreset = NormalizePreset(cfg.ReportPreset);
|
||||
cfg.Comment = NormalizeNullableText(cfg.Comment);
|
||||
cfg.Canceled = false;
|
||||
cfg.Error = null;
|
||||
cfg.RecentFailures = new List<MessageReportTaskRuntimeFailure>();
|
||||
|
||||
return cfg;
|
||||
}
|
||||
|
||||
private static List<int> NormalizeCategoryIds(IEnumerable<int>? values, int fallback)
|
||||
{
|
||||
var ids = (values ?? Array.Empty<int>())
|
||||
.Where(x => x > 0)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
if (ids.Count == 0 && fallback > 0)
|
||||
ids.Add(fallback);
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
private static List<string> NormalizeCategoryNames(IEnumerable<string>? values, string? fallback)
|
||||
{
|
||||
var names = (values ?? Array.Empty<string>())
|
||||
.Select(x => (x ?? string.Empty).Trim())
|
||||
.Where(x => x.Length > 0)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
var fallbackName = (fallback ?? string.Empty).Trim();
|
||||
if (names.Count == 0 && fallbackName.Length > 0)
|
||||
names.Add(fallbackName);
|
||||
|
||||
return names;
|
||||
}
|
||||
|
||||
private static string NormalizePreset(string? preset)
|
||||
{
|
||||
var value = (preset ?? string.Empty).Trim().ToLowerInvariant();
|
||||
return value switch
|
||||
{
|
||||
MessageReportTaskPresets.Spam => MessageReportTaskPresets.Spam,
|
||||
MessageReportTaskPresets.Violence => MessageReportTaskPresets.Violence,
|
||||
MessageReportTaskPresets.Pornography => MessageReportTaskPresets.Pornography,
|
||||
MessageReportTaskPresets.ChildAbuse => MessageReportTaskPresets.ChildAbuse,
|
||||
MessageReportTaskPresets.Copyright => MessageReportTaskPresets.Copyright,
|
||||
MessageReportTaskPresets.IllegalDrugs => MessageReportTaskPresets.IllegalDrugs,
|
||||
MessageReportTaskPresets.PersonalDetails => MessageReportTaskPresets.PersonalDetails,
|
||||
MessageReportTaskPresets.Other => MessageReportTaskPresets.Other,
|
||||
MessageReportTaskPresets.FirstAvailable => MessageReportTaskPresets.FirstAvailable,
|
||||
MessageReportTaskPresets.Custom => MessageReportTaskPresets.Custom,
|
||||
_ => MessageReportTaskPresets.Spam
|
||||
};
|
||||
}
|
||||
|
||||
private static string? NormalizeNullableText(string? value)
|
||||
{
|
||||
var text = (value ?? string.Empty).Trim();
|
||||
return text.Length == 0 ? null : text;
|
||||
}
|
||||
}
|
||||
@@ -1,483 +0,0 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using TelegramPanel.Core.Interfaces;
|
||||
using TelegramPanel.Core.Services.Telegram;
|
||||
using TL;
|
||||
using WTelegram;
|
||||
|
||||
namespace TelegramPanel.Modules.MessageReportTask.Services;
|
||||
|
||||
public sealed class TelegramMessageReportService
|
||||
{
|
||||
private readonly AccountTelegramToolsService _accountTools;
|
||||
private readonly ITelegramClientPool _clientPool;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly ILogger<TelegramMessageReportService> _logger;
|
||||
|
||||
public TelegramMessageReportService(
|
||||
AccountTelegramToolsService accountTools,
|
||||
ITelegramClientPool clientPool,
|
||||
IConfiguration configuration,
|
||||
ILogger<TelegramMessageReportService> logger)
|
||||
{
|
||||
_accountTools = accountTools;
|
||||
_clientPool = clientPool;
|
||||
_configuration = configuration;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public sealed record ResolvedReportMessageTarget(
|
||||
AccountTelegramToolsService.ResolvedChatTarget Chat,
|
||||
int MessageId,
|
||||
string CanonicalLink,
|
||||
string RawInput);
|
||||
|
||||
public sealed record ReportMessageResult(
|
||||
bool Success,
|
||||
string? Error,
|
||||
string? SelectedOptionText,
|
||||
bool ShouldRemoveTarget);
|
||||
|
||||
private sealed record ParsedMessageLink(string ResolveTarget, int MessageId, string CanonicalLink);
|
||||
|
||||
public async Task<(bool Success, string? Error, ResolvedReportMessageTarget? Target)> ResolveMessageTargetAsync(
|
||||
int accountId,
|
||||
string rawInput,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var parsed = ParseMessageLink(rawInput);
|
||||
var resolved = await _accountTools.ResolveChatTargetAsync(accountId, parsed.ResolveTarget, cancellationToken);
|
||||
if (!resolved.Success || resolved.Target == null)
|
||||
return (false, resolved.Error ?? "无法解析消息链接", null);
|
||||
|
||||
return (true, null, new ResolvedReportMessageTarget(resolved.Target, parsed.MessageId, parsed.CanonicalLink, (rawInput ?? string.Empty).Trim()));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var message = ex is ArgumentException or FormatException or InvalidOperationException
|
||||
? ex.Message
|
||||
: BuildTelegramError(ex);
|
||||
return (false, message, null);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ReportMessageResult> ReportMessageAsync(
|
||||
int accountId,
|
||||
ResolvedReportMessageTarget target,
|
||||
MessageReportTaskConfig config,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var client = await EnsureConnectedClientAsync(accountId, target.Chat.CanonicalId, cancellationToken);
|
||||
|
||||
byte[] option = Array.Empty<byte>();
|
||||
var comment = string.Empty;
|
||||
string? selectedOptionText = null;
|
||||
|
||||
var result = await ExecuteTelegramRequestAsync(
|
||||
accountId,
|
||||
$"举报消息({target.CanonicalLink})",
|
||||
() => client.Messages_Report(target.Chat.Peer, new[] { target.MessageId }, option, comment),
|
||||
cancellationToken,
|
||||
resetClientOnTimeout: true);
|
||||
|
||||
for (var depth = 0; depth < 8; depth++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
switch (result)
|
||||
{
|
||||
case ReportResultReported:
|
||||
return new ReportMessageResult(true, null, selectedOptionText, true);
|
||||
|
||||
case ReportResultChooseOption choose:
|
||||
{
|
||||
var selected = SelectOption(choose.options, config);
|
||||
if (selected == null)
|
||||
{
|
||||
var available = BuildOptionListText(choose.options);
|
||||
var error = available.Length == 0
|
||||
? "Telegram 返回了空的举报选项列表"
|
||||
: $"未匹配到举报类型,对应可选项:{available}";
|
||||
return new ReportMessageResult(false, error, selectedOptionText, true);
|
||||
}
|
||||
|
||||
option = selected.option ?? Array.Empty<byte>();
|
||||
selectedOptionText = NormalizeNullableText(selected.text);
|
||||
|
||||
result = await ExecuteTelegramRequestAsync(
|
||||
accountId,
|
||||
$"选择举报类型({selectedOptionText ?? "未命名选项"})",
|
||||
() => client.Messages_Report(target.Chat.Peer, new[] { target.MessageId }, option, comment),
|
||||
cancellationToken,
|
||||
resetClientOnTimeout: true);
|
||||
break;
|
||||
}
|
||||
|
||||
case ReportResultAddComment addComment:
|
||||
{
|
||||
option = addComment.option ?? option;
|
||||
comment = BuildComment(config.Comment, target);
|
||||
var optional = addComment.flags.HasFlag(ReportResultAddComment.Flags.optional);
|
||||
if (string.IsNullOrWhiteSpace(comment) && !optional)
|
||||
return new ReportMessageResult(false, "当前举报类型要求填写举报文案,请先补充文案后再执行", selectedOptionText, true);
|
||||
|
||||
result = await ExecuteTelegramRequestAsync(
|
||||
accountId,
|
||||
$"提交举报文案({target.CanonicalLink})",
|
||||
() => client.Messages_Report(target.Chat.Peer, new[] { target.MessageId }, option, comment),
|
||||
cancellationToken,
|
||||
resetClientOnTimeout: true);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
return new ReportMessageResult(false, $"不支持的举报返回类型:{result.GetType().Name}", selectedOptionText, true);
|
||||
}
|
||||
}
|
||||
|
||||
return new ReportMessageResult(false, "举报流程超过安全重试次数,请稍后再试", selectedOptionText, false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var error = BuildTelegramError(ex);
|
||||
return new ReportMessageResult(false, error, null, ShouldRemoveTargetAfterFailure(error));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Client> EnsureConnectedClientAsync(int accountId, string resolveHint, CancellationToken cancellationToken)
|
||||
{
|
||||
var existing = _clientPool.GetClient(accountId);
|
||||
if (existing?.User != null)
|
||||
return existing;
|
||||
|
||||
var resolved = await _accountTools.ResolveChatTargetAsync(accountId, resolveHint, cancellationToken);
|
||||
if (!resolved.Success)
|
||||
throw new InvalidOperationException(resolved.Error ?? "无法建立 Telegram 客户端连接");
|
||||
|
||||
var client = _clientPool.GetClient(accountId);
|
||||
if (client?.User != null)
|
||||
return client;
|
||||
|
||||
throw new InvalidOperationException("无法建立 Telegram 客户端连接");
|
||||
}
|
||||
|
||||
private static ParsedMessageLink ParseMessageLink(string rawInput)
|
||||
{
|
||||
var raw = (rawInput ?? string.Empty).Trim();
|
||||
if (raw.Length == 0)
|
||||
throw new ArgumentException("消息链接为空", nameof(rawInput));
|
||||
|
||||
if (raw.StartsWith("tg://", StringComparison.OrdinalIgnoreCase)
|
||||
&& Uri.TryCreate(raw, UriKind.Absolute, out var tgUri)
|
||||
&& string.Equals(tgUri.Host, "privatepost", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var query = ParseQueryString(tgUri.Query);
|
||||
if (!query.TryGetValue("channel", out var channelText) || string.IsNullOrWhiteSpace(channelText))
|
||||
throw new ArgumentException("tg://privatepost 缺少 channel 参数", nameof(rawInput));
|
||||
if (!query.TryGetValue("post", out var postText) || string.IsNullOrWhiteSpace(postText))
|
||||
throw new ArgumentException("tg://privatepost 缺少 post 参数", nameof(rawInput));
|
||||
|
||||
var internalId = NormalizeInternalChatId(channelText);
|
||||
var messageId = ParsePositiveMessageId(postText);
|
||||
return new ParsedMessageLink($"-100{internalId}", messageId, $"https://t.me/c/{internalId}/{messageId}");
|
||||
}
|
||||
|
||||
var normalizedUrl = raw;
|
||||
if (normalizedUrl.StartsWith("t.me/", StringComparison.OrdinalIgnoreCase)
|
||||
|| normalizedUrl.StartsWith("telegram.me/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
normalizedUrl = "https://" + normalizedUrl;
|
||||
}
|
||||
|
||||
if (Uri.TryCreate(normalizedUrl, UriKind.Absolute, out var uri)
|
||||
&& (string.Equals(uri.Host, "t.me", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(uri.Host, "telegram.me", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
var segments = (uri.AbsolutePath ?? string.Empty)
|
||||
.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
return ParseSegments(segments, rawInput);
|
||||
}
|
||||
|
||||
var plain = raw;
|
||||
var queryIndex = plain.IndexOfAny(new[] { '?', '#' });
|
||||
if (queryIndex >= 0)
|
||||
plain = plain[..queryIndex];
|
||||
|
||||
var pathSegments = plain
|
||||
.Trim('/')
|
||||
.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
return ParseSegments(pathSegments, rawInput);
|
||||
}
|
||||
|
||||
private static ParsedMessageLink ParseSegments(string[] segments, string? rawInput)
|
||||
{
|
||||
if (segments.Length >= 3 && string.Equals(segments[0], "c", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var internalId = NormalizeInternalChatId(segments[1]);
|
||||
var messageId = ParsePositiveMessageId(segments[2]);
|
||||
return new ParsedMessageLink($"-100{internalId}", messageId, $"https://t.me/c/{internalId}/{messageId}");
|
||||
}
|
||||
|
||||
if (segments.Length >= 3 && (string.Equals(segments[0], "b", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(segments[0], "s", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
var target = NormalizeTargetToken(segments[1]);
|
||||
var messageId = ParsePositiveMessageId(segments[2]);
|
||||
return new ParsedMessageLink(target, messageId, $"https://t.me/{target.TrimStart('@')}/{messageId}");
|
||||
}
|
||||
|
||||
if (segments.Length >= 2)
|
||||
{
|
||||
var target = NormalizeTargetToken(segments[0]);
|
||||
var messageId = ParsePositiveMessageId(segments[1]);
|
||||
if (long.TryParse(target, NumberStyles.Integer, CultureInfo.InvariantCulture, out _))
|
||||
return new ParsedMessageLink(target, messageId, $"{target}/{messageId}");
|
||||
|
||||
return new ParsedMessageLink(target, messageId, $"https://t.me/{target.TrimStart('@')}/{messageId}");
|
||||
}
|
||||
|
||||
throw new ArgumentException($"不支持的消息链接格式:{(rawInput ?? string.Empty).Trim()}", nameof(rawInput));
|
||||
}
|
||||
|
||||
private static string NormalizeInternalChatId(string value)
|
||||
{
|
||||
var text = (value ?? string.Empty).Trim();
|
||||
if (text.StartsWith("-100", StringComparison.Ordinal))
|
||||
text = text[4..];
|
||||
|
||||
if (!long.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) || parsed <= 0)
|
||||
throw new FormatException($"私有消息链接中的频道 ID 无效:{value}");
|
||||
|
||||
return parsed.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private static string NormalizeTargetToken(string value)
|
||||
{
|
||||
var text = (value ?? string.Empty).Trim();
|
||||
if (text.Length == 0)
|
||||
throw new FormatException("消息链接中缺少目标标识");
|
||||
|
||||
if (long.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out _))
|
||||
return text;
|
||||
|
||||
return text.TrimStart('@');
|
||||
}
|
||||
|
||||
private static int ParsePositiveMessageId(string value)
|
||||
{
|
||||
var text = (value ?? string.Empty).Trim();
|
||||
if (!int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) || parsed <= 0)
|
||||
throw new FormatException($"消息 ID 无效:{value}");
|
||||
return parsed;
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> ParseQueryString(string query)
|
||||
{
|
||||
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
var text = (query ?? string.Empty).Trim();
|
||||
if (text.StartsWith("?", StringComparison.Ordinal))
|
||||
text = text[1..];
|
||||
if (text.Length == 0)
|
||||
return result;
|
||||
|
||||
foreach (var pair in text.Split('&', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
var parts = pair.Split('=', 2);
|
||||
var key = Uri.UnescapeDataString((parts[0] ?? string.Empty).Trim());
|
||||
if (key.Length == 0)
|
||||
continue;
|
||||
|
||||
var value = parts.Length > 1 ? Uri.UnescapeDataString((parts[1] ?? string.Empty).Trim()) : string.Empty;
|
||||
result[key] = value;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static MessageReportOption? SelectOption(IEnumerable<MessageReportOption>? options, MessageReportTaskConfig config)
|
||||
{
|
||||
var list = (options ?? Array.Empty<MessageReportOption>())
|
||||
.Where(x => x != null)
|
||||
.ToList();
|
||||
if (list.Count == 0)
|
||||
return null;
|
||||
|
||||
if (string.Equals(config.ReportPreset, MessageReportTaskPresets.FirstAvailable, StringComparison.OrdinalIgnoreCase))
|
||||
return list[0];
|
||||
|
||||
var normalizedKeywords = BuildNormalizedKeywords(config);
|
||||
if (normalizedKeywords.Count == 0)
|
||||
return null;
|
||||
|
||||
foreach (var option in list)
|
||||
{
|
||||
var text = NormalizeKeyword(option.text);
|
||||
if (text.Length == 0)
|
||||
continue;
|
||||
|
||||
if (normalizedKeywords.Any(keyword => text.Contains(keyword, StringComparison.Ordinal)))
|
||||
return option;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static List<string> BuildNormalizedKeywords(MessageReportTaskConfig config)
|
||||
{
|
||||
var custom = (config.OptionKeywords ?? new List<string>())
|
||||
.Select(NormalizeKeyword)
|
||||
.Where(x => x.Length > 0)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
if (custom.Count > 0)
|
||||
return custom;
|
||||
|
||||
return GetPresetKeywords(config.ReportPreset)
|
||||
.Select(NormalizeKeyword)
|
||||
.Where(x => x.Length > 0)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static IEnumerable<string> GetPresetKeywords(string? preset)
|
||||
{
|
||||
return (preset ?? string.Empty).Trim().ToLowerInvariant() switch
|
||||
{
|
||||
MessageReportTaskPresets.Spam => new[] { "spam", "垃圾", "骚扰", "广告" },
|
||||
MessageReportTaskPresets.Violence => new[] { "violence", "violent", "暴力", "威胁" },
|
||||
MessageReportTaskPresets.Pornography => new[] { "porn", "pornography", "色情", "淫秽" },
|
||||
MessageReportTaskPresets.ChildAbuse => new[] { "child", "childabuse", "儿童", "未成年人" },
|
||||
MessageReportTaskPresets.Copyright => new[] { "copyright", "侵权", "版权" },
|
||||
MessageReportTaskPresets.IllegalDrugs => new[] { "drugs", "illegaldrugs", "毒品", "违禁药物" },
|
||||
MessageReportTaskPresets.PersonalDetails => new[] { "personal", "personaldetails", "隐私", "个人信息" },
|
||||
MessageReportTaskPresets.Other => new[] { "other", "其他" },
|
||||
_ => Array.Empty<string>()
|
||||
};
|
||||
}
|
||||
|
||||
private static string BuildComment(string? template, ResolvedReportMessageTarget target)
|
||||
{
|
||||
var text = (template ?? string.Empty).Trim();
|
||||
if (text.Length == 0)
|
||||
return string.Empty;
|
||||
|
||||
return text
|
||||
.Replace("{消息链接}", target.CanonicalLink, StringComparison.OrdinalIgnoreCase)
|
||||
.Replace("{message_link}", target.CanonicalLink, StringComparison.OrdinalIgnoreCase)
|
||||
.Replace("{聊天标题}", target.Chat.Title, StringComparison.OrdinalIgnoreCase)
|
||||
.Replace("{chat_title}", target.Chat.Title, StringComparison.OrdinalIgnoreCase)
|
||||
.Replace("{消息ID}", target.MessageId.ToString(CultureInfo.InvariantCulture), StringComparison.OrdinalIgnoreCase)
|
||||
.Replace("{message_id}", target.MessageId.ToString(CultureInfo.InvariantCulture), StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string BuildOptionListText(IEnumerable<MessageReportOption>? options)
|
||||
{
|
||||
return string.Join(" / ", (options ?? Array.Empty<MessageReportOption>())
|
||||
.Select(x => NormalizeNullableText(x?.text))
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static string NormalizeKeyword(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return string.Empty;
|
||||
|
||||
var builder = new StringBuilder(value.Length);
|
||||
foreach (var ch in value.Trim().ToLowerInvariant())
|
||||
{
|
||||
if (char.IsLetterOrDigit(ch) || ch >= 0x4e00)
|
||||
builder.Append(ch);
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static string? NormalizeNullableText(string? value)
|
||||
{
|
||||
var text = (value ?? string.Empty).Trim();
|
||||
return text.Length == 0 ? null : text;
|
||||
}
|
||||
|
||||
private string BuildTelegramError(Exception ex)
|
||||
{
|
||||
var (summary, details) = AccountTelegramToolsService.MapTelegramException(ex);
|
||||
return string.IsNullOrWhiteSpace(details) ? summary : $"{summary}:{details}";
|
||||
}
|
||||
|
||||
private static bool ShouldRemoveTargetAfterFailure(string error)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(error))
|
||||
return false;
|
||||
|
||||
if (LooksLikeTemporaryFailure(error))
|
||||
return false;
|
||||
|
||||
return error.Contains("ALREADY_REPORTED", StringComparison.OrdinalIgnoreCase)
|
||||
|| error.Contains("MESSAGE_ALREADY_REPORTED", StringComparison.OrdinalIgnoreCase)
|
||||
|| error.Contains("MESSAGE_ID_INVALID", StringComparison.OrdinalIgnoreCase)
|
||||
|| error.Contains("MSG_ID_INVALID", StringComparison.OrdinalIgnoreCase)
|
||||
|| error.Contains("MESSAGE_NOT_FOUND", StringComparison.OrdinalIgnoreCase)
|
||||
|| error.Contains("CHAT_ID_INVALID", StringComparison.OrdinalIgnoreCase)
|
||||
|| error.Contains("PEER_ID_INVALID", StringComparison.OrdinalIgnoreCase)
|
||||
|| error.Contains("CHANNEL_INVALID", StringComparison.OrdinalIgnoreCase)
|
||||
|| error.Contains("CHANNEL_PRIVATE", StringComparison.OrdinalIgnoreCase)
|
||||
|| error.Contains("GROUPED_MEDIA_INVALID", StringComparison.OrdinalIgnoreCase)
|
||||
|| error.Contains("未匹配到举报类型", StringComparison.OrdinalIgnoreCase)
|
||||
|| error.Contains("要求填写举报文案", StringComparison.OrdinalIgnoreCase)
|
||||
|| error.Contains("不支持的举报返回类型", StringComparison.OrdinalIgnoreCase)
|
||||
|| error.Contains("链接格式", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool LooksLikeTemporaryFailure(string error)
|
||||
{
|
||||
return error.Contains("FLOOD_WAIT", StringComparison.OrdinalIgnoreCase)
|
||||
|| error.Contains("请求超时", StringComparison.OrdinalIgnoreCase)
|
||||
|| error.Contains("timeout", StringComparison.OrdinalIgnoreCase)
|
||||
|| error.Contains("timed out", StringComparison.OrdinalIgnoreCase)
|
||||
|| error.Contains("网络", StringComparison.OrdinalIgnoreCase)
|
||||
|| error.Contains("NETWORK", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private TimeSpan GetTelegramRequestTimeout()
|
||||
{
|
||||
var seconds = int.TryParse(_configuration["Telegram:RequestTimeoutSeconds"], out var parsedSeconds)
|
||||
? parsedSeconds
|
||||
: 90;
|
||||
return TimeSpan.FromSeconds(Math.Clamp(seconds, 15, 600));
|
||||
}
|
||||
|
||||
private async Task<T> ExecuteTelegramRequestAsync<T>(
|
||||
int accountId,
|
||||
string operation,
|
||||
Func<Task<T>> action,
|
||||
CancellationToken cancellationToken,
|
||||
bool resetClientOnTimeout)
|
||||
{
|
||||
var timeout = GetTelegramRequestTimeout();
|
||||
|
||||
try
|
||||
{
|
||||
return await action().WaitAsync(timeout, cancellationToken);
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Telegram request timed out after {TimeoutSeconds}s for account {AccountId}: {Operation}",
|
||||
timeout.TotalSeconds,
|
||||
accountId,
|
||||
operation);
|
||||
|
||||
if (resetClientOnTimeout)
|
||||
await _clientPool.RemoveClientAsync(accountId);
|
||||
|
||||
throw new TimeoutException($"Telegram 请求超时:{operation} 超过 {timeout.TotalSeconds:0} 秒,可能是账号受限、网络异常或代理异常");
|
||||
}
|
||||
}
|
||||
}
|
||||
-33
@@ -1,33 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Razor">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>TelegramPanel.Modules.MessageReportTask</RootNamespace>
|
||||
<AssemblyName>TelegramPanel.Modules.MessageReportTask</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\TelegramPanel.Modules.Abstractions\TelegramPanel.Modules.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\TelegramPanel.Core\TelegramPanel.Core.csproj" />
|
||||
<ProjectReference Include="..\TelegramPanel.Data\TelegramPanel.Data.csproj" />
|
||||
<PackageReference Include="MudBlazor" Version="7.*" />
|
||||
<PackageReference Include="WTelegramClient" Version="4.*" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Remove="publish/**" />
|
||||
<None Remove="publish/**" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="manifest.json">
|
||||
<CopyToOutputDirectory>Never</CopyToOutputDirectory>
|
||||
<CopyToPublishDirectory>Never</CopyToPublishDirectory>
|
||||
</None>
|
||||
<Content Update="manifest.json">
|
||||
<CopyToOutputDirectory>Never</CopyToOutputDirectory>
|
||||
<CopyToPublishDirectory>Never</CopyToPublishDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -1,5 +0,0 @@
|
||||
@using Microsoft.AspNetCore.Components
|
||||
@using MudBlazor
|
||||
@using TelegramPanel.Core.Services
|
||||
@using TelegramPanel.Data.Entities
|
||||
@using TelegramPanel.Modules
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"id": "task.message-report",
|
||||
"name": "任务:消息举报",
|
||||
"version": "1.0.0",
|
||||
"host": {
|
||||
"min": "1.31.6",
|
||||
"max": "2.0.0"
|
||||
},
|
||||
"entry": {
|
||||
"assembly": "TelegramPanel.Modules.MessageReportTask.dll",
|
||||
"type": "TelegramPanel.Modules.MessageReportTask.MessageReportTaskModule"
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user