diff --git a/TelegramPanel.sln b/TelegramPanel.sln
index a899d77..228ca1e 100644
--- a/TelegramPanel.sln
+++ b/TelegramPanel.sln
@@ -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
diff --git a/src/TelegramPanel.Modules.MessageReport/MessageReportTaskEditor.razor b/src/TelegramPanel.Modules.MessageReport/MessageReportTaskEditor.razor
deleted file mode 100644
index 52bcda7..0000000
--- a/src/TelegramPanel.Modules.MessageReport/MessageReportTaskEditor.razor
+++ /dev/null
@@ -1,312 +0,0 @@
-@using System.Text.Json
-@using TelegramPanel.Core.Services
-@using TelegramPanel.Data.Entities
-@inject AccountCategoryManagementService CategoryManagement
-@inject AccountManagementService AccountManagement
-
-
-
- 该任务会按账号分类轮流举报指定消息。MaxReports=0 表示持续运行直到你在任务中心手动暂停/停止;如果在外层选择 Cron 计划,建议配合有限次数使用。
-
-
- @if (loading)
- {
-
- 正在加载账号分类...
- }
- else
- {
-
- @foreach (var category in categories)
- {
- @category.Name
- }
-
-
-
- 当前选中分类下可用账号数:@selectedAccountCount
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 垃圾/广告
- 暴力
- 色情/成人
- 儿童/未成年相关
- 版权/侵权
- 隐私/个人信息
- 毒品/违禁药
- 诈骗/冒充/虚假
- 其他
- 自动(取 Telegram 返回的首项)
-
-
-
-
-
-
-
- 队列循环
- 随机
-
- }
-
-
-@code {
- [Parameter] public ModuleTaskDraft Draft { get; set; }
- [Parameter] public EventCallback DraftChanged { get; set; }
- [Parameter] public string? InitialConfigJson { get; set; }
-
- private List categories = new();
- private List activeAccounts = new();
- private bool loading = true;
- private bool initializedFromConfig;
-
- private IEnumerable selectedCategoryIds = Array.Empty();
- 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 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();
- }
- }
- 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(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());
- 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());
- commentsText = string.Join(Environment.NewLine, cfg.Comments ?? new List());
- 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();
- }
- catch
- {
- }
- }
-
- private async Task OnSelectedCategoriesChanged(IEnumerable 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);
- }
-}
diff --git a/src/TelegramPanel.Modules.MessageReport/MessageReportTaskHandler.cs b/src/TelegramPanel.Modules.MessageReport/MessageReportTaskHandler.cs
deleted file mode 100644
index 1263b7f..0000000
--- a/src/TelegramPanel.Modules.MessageReport/MessageReportTaskHandler.cs
+++ /dev/null
@@ -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>();
- var taskManagement = host.Services.GetRequiredService();
- var accountManagement = host.Services.GetRequiredService();
- var telegramService = host.Services.GetRequiredService();
-
- 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(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(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();
-
- 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 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();
- 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 });
- }
-}
diff --git a/src/TelegramPanel.Modules.MessageReport/MessageReportTaskModels.cs b/src/TelegramPanel.Modules.MessageReport/MessageReportTaskModels.cs
deleted file mode 100644
index 4230f54..0000000
--- a/src/TelegramPanel.Modules.MessageReport/MessageReportTaskModels.cs
+++ /dev/null
@@ -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 CategoryIds { get; set; } = new();
-
- [JsonPropertyName("category_names")]
- public List CategoryNames { get; set; } = new();
-
- [JsonPropertyName("message_references")]
- public List 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 OptionKeywords { get; set; } = new();
-
- [JsonPropertyName("comments")]
- public List 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 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 NormalizeCategoryIds(IEnumerable? values, int fallback)
- {
- var ids = (values ?? Array.Empty())
- .Where(x => x > 0)
- .Distinct()
- .ToList();
-
- if (ids.Count == 0 && fallback > 0)
- ids.Add(fallback);
-
- return ids;
- }
-
- public static List NormalizeCategoryNames(IEnumerable? 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 NormalizeLines(IEnumerable? values, bool distinct = false)
- {
- var lines = (values ?? Array.Empty())
- .Select(x => (x ?? string.Empty).Trim())
- .Where(x => x.Length > 0)
- .ToList();
-
- return distinct
- ? lines.Distinct(StringComparer.OrdinalIgnoreCase).ToList()
- : lines;
- }
-
- public static List ParseMultilineText(string? text, bool distinct = false)
- {
- if (string.IsNullOrWhiteSpace(text))
- return new List();
-
- 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('/');
- }
-}
diff --git a/src/TelegramPanel.Modules.MessageReport/MessageReportTaskModule.cs b/src/TelegramPanel.Modules.MessageReport/MessageReportTaskModule.cs
deleted file mode 100644
index 3195629..0000000
--- a/src/TelegramPanel.Modules.MessageReport/MessageReportTaskModule.cs
+++ /dev/null
@@ -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();
- services.AddScoped();
- services.AddSingleton();
- }
-
- public void MapEndpoints(IEndpointRouteBuilder endpoints, ModuleHostContext context)
- {
- }
-
- public IEnumerable 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
- };
- }
-}
diff --git a/src/TelegramPanel.Modules.MessageReport/MessageReportTaskRerunBuilder.cs b/src/TelegramPanel.Modules.MessageReport/MessageReportTaskRerunBuilder.cs
deleted file mode 100644
index 6a47ad2..0000000
--- a/src/TelegramPanel.Modules.MessageReport/MessageReportTaskRerunBuilder.cs
+++ /dev/null
@@ -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(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();
- return cfg;
- }
-}
diff --git a/src/TelegramPanel.Modules.MessageReport/MessageReportTaskTelegramService.cs b/src/TelegramPanel.Modules.MessageReport/MessageReportTaskTelegramService.cs
deleted file mode 100644
index 0739c3dd..0000000
--- a/src/TelegramPanel.Modules.MessageReport/MessageReportTaskTelegramService.cs
+++ /dev/null
@@ -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 ReportTypeKeywords = new Dictionary(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 _logger;
-
- public MessageReportTaskTelegramService(
- AccountManagementService accountManagement,
- ITelegramClientPool clientPool,
- AccountTelegramToolsService accountTools,
- IConfiguration configuration,
- ILogger 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 ReportMessageAsync(
- int accountId,
- ResolvedMessageTarget target,
- MessageReportTaskConfig config,
- int commentCursor,
- CancellationToken cancellationToken)
- {
- try
- {
- var client = await GetOrCreateConnectedClientAsync(accountId, cancellationToken);
- var option = Array.Empty();
- 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();
- 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 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 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 ExecuteTelegramRequestAsync(
- int accountId,
- string operation,
- Func> 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? options,
- string reportType,
- IEnumerable? customKeywords,
- out string? error)
- {
- error = null;
- var optionList = (options ?? Array.Empty()).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 BuildKeywords(string reportType, IEnumerable? 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? 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);
-}
diff --git a/src/TelegramPanel.Modules.MessageReport/TelegramPanel.Modules.MessageReport.csproj b/src/TelegramPanel.Modules.MessageReport/TelegramPanel.Modules.MessageReport.csproj
deleted file mode 100644
index f1c4730..0000000
--- a/src/TelegramPanel.Modules.MessageReport/TelegramPanel.Modules.MessageReport.csproj
+++ /dev/null
@@ -1,23 +0,0 @@
-
-
- net8.0
- enable
- enable
- TelegramPanel.Modules.MessageReport
- TelegramPanel.Modules.MessageReport
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/TelegramPanel.Modules.MessageReport/_Imports.razor b/src/TelegramPanel.Modules.MessageReport/_Imports.razor
deleted file mode 100644
index 51a4aef..0000000
--- a/src/TelegramPanel.Modules.MessageReport/_Imports.razor
+++ /dev/null
@@ -1,4 +0,0 @@
-@using Microsoft.AspNetCore.Components
-@using MudBlazor
-@using TelegramPanel.Modules
-@using TelegramPanel.Modules.MessageReport
diff --git a/src/TelegramPanel.Modules.MessageReport/manifest.json b/src/TelegramPanel.Modules.MessageReport/manifest.json
deleted file mode 100644
index 5fb95d4..0000000
--- a/src/TelegramPanel.Modules.MessageReport/manifest.json
+++ /dev/null
@@ -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"
- }
-}
diff --git a/src/TelegramPanel.Modules.MessageReportTask/Components/Dialogs/MessageReportTaskEditor.razor b/src/TelegramPanel.Modules.MessageReportTask/Components/Dialogs/MessageReportTaskEditor.razor
deleted file mode 100644
index 9a2c644..0000000
--- a/src/TelegramPanel.Modules.MessageReportTask/Components/Dialogs/MessageReportTaskEditor.razor
+++ /dev/null
@@ -1,315 +0,0 @@
-@using System.Text.Json
-@using TelegramPanel.Modules.MessageReportTask.Services
-@inject AccountCategoryManagementService CategoryManagement
-
-
-
- 该任务会按账号分类轮流对指定消息链接执行举报。最多举报次数为 0 表示不设上限,直到所有可用账号-消息组合处理完成或你在任务中心手动停止。
-
-
-
- Telegram 的消息举报是动态选项流程。这里的“举报类型”本质上是匹配 Telegram 返回的选项文字;如果匹配不到,任务会记失败,但不会盲选错误类型。
-
-
- @if (loading)
- {
-
- 正在加载账号分类...
- }
- else
- {
-
- @foreach (var c in categories)
- {
- @c.Name
- }
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 垃圾 / 骚扰
- 暴力 / 威胁
- 色情 / 淫秽
- 儿童虐待
- 版权侵权
- 违禁药物
- 隐私 / 个人信息
- 其他
- 直接选第一个选项
- 自定义关键词
-
-
-
-
-
- }
-
-
-@code {
- [Parameter] public ModuleTaskDraft Draft { get; set; }
- [Parameter] public EventCallback DraftChanged { get; set; }
- [Parameter] public string? InitialConfigJson { get; set; }
-
- private List categories = new();
- private bool loading = true;
- private bool initializedFromConfig;
-
- private IEnumerable selectedCategoryIds = Array.Empty();
- 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();
- }
- }
- 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(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());
- 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());
- commentText = (cfg.Comment ?? string.Empty).Trim();
- }
- catch
- {
- }
- }
-
- private async Task OnSelectedCategoriesChanged(IEnumerable 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 NormalizeCategoryIds(IEnumerable? values, int fallback)
- {
- var ids = (values ?? Array.Empty())
- .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 ParseLines(string? text, bool distinct)
- {
- if (string.IsNullOrWhiteSpace(text))
- return new List();
-
- 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;
- }
-}
diff --git a/src/TelegramPanel.Modules.MessageReportTask/MessageReportTaskModule.cs b/src/TelegramPanel.Modules.MessageReportTask/MessageReportTaskModule.cs
deleted file mode 100644
index 6c611e4..0000000
--- a/src/TelegramPanel.Modules.MessageReportTask/MessageReportTaskModule.cs
+++ /dev/null
@@ -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();
- services.AddScoped();
- services.AddSingleton();
- }
-
- public void MapEndpoints(IEndpointRouteBuilder endpoints, ModuleHostContext context)
- {
- }
-
- public IEnumerable 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
- };
- }
-}
diff --git a/src/TelegramPanel.Modules.MessageReportTask/Services/MessageReportTaskHandler.cs b/src/TelegramPanel.Modules.MessageReportTask/Services/MessageReportTaskHandler.cs
deleted file mode 100644
index 3b88951..0000000
--- a/src/TelegramPanel.Modules.MessageReportTask/Services/MessageReportTaskHandler.cs
+++ /dev/null
@@ -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>();
- var taskManagement = host.Services.GetRequiredService();
- var accountManagement = host.Services.GetRequiredService();
- var reportService = host.Services.GetRequiredService();
-
- 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();
- 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();
- 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(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())
- .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())
- .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();
- }
-
- private static List NormalizeSelectedCategoryIds(MessageReportTaskConfig config)
- {
- var ids = (config.CategoryIds ?? new List())
- .Where(x => x > 0)
- .Distinct()
- .ToList();
-
- if (ids.Count == 0 && config.CategoryId > 0)
- ids.Add(config.CategoryId);
-
- return ids;
- }
-
- private static List NormalizeCategoryNames(IEnumerable? values, string? fallback)
- {
- var names = (values ?? Array.Empty())
- .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 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 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 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();
- 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 Targets { get; } = new();
- }
-
- private sealed class TargetSlot
- {
- public TargetSlot(TelegramMessageReportService.ResolvedReportMessageTarget target)
- {
- Target = target;
- }
-
- public TelegramMessageReportService.ResolvedReportMessageTarget Target { get; }
- public int ConsecutiveFailures { get; set; }
- }
-}
diff --git a/src/TelegramPanel.Modules.MessageReportTask/Services/MessageReportTaskModels.cs b/src/TelegramPanel.Modules.MessageReportTask/Services/MessageReportTaskModels.cs
deleted file mode 100644
index 2b0242d..0000000
--- a/src/TelegramPanel.Modules.MessageReportTask/Services/MessageReportTaskModels.cs
+++ /dev/null
@@ -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 CategoryIds { get; set; } = new();
-
- [JsonPropertyName("category_names")]
- public List CategoryNames { get; set; } = new();
-
- [JsonPropertyName("message_links")]
- public List 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 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 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;
-}
diff --git a/src/TelegramPanel.Modules.MessageReportTask/Services/MessageReportTaskRerunBuilder.cs b/src/TelegramPanel.Modules.MessageReportTask/Services/MessageReportTaskRerunBuilder.cs
deleted file mode 100644
index 8c17012..0000000
--- a/src/TelegramPanel.Modules.MessageReportTask/Services/MessageReportTaskRerunBuilder.cs
+++ /dev/null
@@ -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(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())
- .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())
- .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();
-
- return cfg;
- }
-
- private static List NormalizeCategoryIds(IEnumerable? values, int fallback)
- {
- var ids = (values ?? Array.Empty())
- .Where(x => x > 0)
- .Distinct()
- .ToList();
-
- if (ids.Count == 0 && fallback > 0)
- ids.Add(fallback);
-
- return ids;
- }
-
- private static List NormalizeCategoryNames(IEnumerable? values, string? fallback)
- {
- var names = (values ?? Array.Empty())
- .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;
- }
-}
diff --git a/src/TelegramPanel.Modules.MessageReportTask/Services/TelegramMessageReportService.cs b/src/TelegramPanel.Modules.MessageReportTask/Services/TelegramMessageReportService.cs
deleted file mode 100644
index 3a09881..0000000
--- a/src/TelegramPanel.Modules.MessageReportTask/Services/TelegramMessageReportService.cs
+++ /dev/null
@@ -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 _logger;
-
- public TelegramMessageReportService(
- AccountTelegramToolsService accountTools,
- ITelegramClientPool clientPool,
- IConfiguration configuration,
- ILogger 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 ReportMessageAsync(
- int accountId,
- ResolvedReportMessageTarget target,
- MessageReportTaskConfig config,
- CancellationToken cancellationToken = default)
- {
- try
- {
- var client = await EnsureConnectedClientAsync(accountId, target.Chat.CanonicalId, cancellationToken);
-
- byte[] option = Array.Empty();
- 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();
- 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 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 ParseQueryString(string query)
- {
- var result = new Dictionary(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? options, MessageReportTaskConfig config)
- {
- var list = (options ?? Array.Empty())
- .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 BuildNormalizedKeywords(MessageReportTaskConfig config)
- {
- var custom = (config.OptionKeywords ?? new List())
- .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 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()
- };
- }
-
- 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? options)
- {
- return string.Join(" / ", (options ?? Array.Empty())
- .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 ExecuteTelegramRequestAsync(
- int accountId,
- string operation,
- Func> 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} 秒,可能是账号受限、网络异常或代理异常");
- }
- }
-}
diff --git a/src/TelegramPanel.Modules.MessageReportTask/TelegramPanel.Modules.MessageReportTask.csproj b/src/TelegramPanel.Modules.MessageReportTask/TelegramPanel.Modules.MessageReportTask.csproj
deleted file mode 100644
index 805a71e..0000000
--- a/src/TelegramPanel.Modules.MessageReportTask/TelegramPanel.Modules.MessageReportTask.csproj
+++ /dev/null
@@ -1,33 +0,0 @@
-
-
- net8.0
- enable
- enable
- TelegramPanel.Modules.MessageReportTask
- TelegramPanel.Modules.MessageReportTask
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Never
- Never
-
-
- Never
- Never
-
-
-
diff --git a/src/TelegramPanel.Modules.MessageReportTask/_Imports.razor b/src/TelegramPanel.Modules.MessageReportTask/_Imports.razor
deleted file mode 100644
index 1ae409c..0000000
--- a/src/TelegramPanel.Modules.MessageReportTask/_Imports.razor
+++ /dev/null
@@ -1,5 +0,0 @@
-@using Microsoft.AspNetCore.Components
-@using MudBlazor
-@using TelegramPanel.Core.Services
-@using TelegramPanel.Data.Entities
-@using TelegramPanel.Modules
diff --git a/src/TelegramPanel.Modules.MessageReportTask/manifest.json b/src/TelegramPanel.Modules.MessageReportTask/manifest.json
deleted file mode 100644
index 7b1c89b..0000000
--- a/src/TelegramPanel.Modules.MessageReportTask/manifest.json
+++ /dev/null
@@ -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"
- }
-}