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" - } -}