diff --git a/TelegramPanel.sln b/TelegramPanel.sln index 301f599..a899d77 100644 --- a/TelegramPanel.sln +++ b/TelegramPanel.sln @@ -1,4 +1,4 @@ -Microsoft Visual Studio Solution File, Format Version 12.00 +Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.0.31903.59 MinimumVisualStudioVersion = 10.0.40219.1 @@ -10,6 +10,12 @@ 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 @@ -32,5 +38,17 @@ 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/docs/developer/modules.md b/docs/developer/modules.md index 345be30..be65553 100644 --- a/docs/developer/modules.md +++ b/docs/developer/modules.md @@ -534,7 +534,56 @@ yield return new ModuleTaskDefinition > 注意:这组字段已经进入抽象层,并且内置持续任务已按此方式声明;外部模块也建议遵循相同结构,便于后续宿主统一扩展任务中心行为。 -### 6) 为“重新运行”提供专用构建器(适合复杂任务) +### 6) 宿主内置数据字典与模板变量(推荐优先复用) + +如果你的模块任务需要“随机文案 / 队列文案 / 图片变量 / 标题模板 / 用户名模板”等能力,建议优先复用宿主已经内置的数据字典体系,而不是在模块里重复造一套词库配置。 + +当前宿主已经提供: + +- 数据字典管理页面:`/data-dictionaries` +- 文本字典:返回 `string` +- 图片字典:返回图片资产引用(适合头像、图片消息等) +- 读取模式:`random` / `queue` +- 队列游标持久化:`queue` 模式的 `NextIndex` 会写入数据库,重启后继续 +- 模板变量语法:固定为 `{name}` +- 内置变量:`{time}`(格式 `yyyyMMddHHmmss`) + +相关宿主服务: + +- `TelegramPanel.Web.Services.DataDictionaryService` +- `TelegramPanel.Web.Services.TemplateRenderingService` +- `TelegramPanel.Web.Services.ImageAssetStorageService` + +推荐用法: + +```csharp +var templateRendering = host.Services.GetRequiredService(); + +var title = await templateRendering.RenderTextTemplateAsync("临时频道{time}_{city}", cancellationToken); +var avatar = await templateRendering.ResolveImageTemplateAsync("{avatar_dict}", cancellationToken); +``` + +约束说明: + +- 标题、描述、公开用户名这类文本字段,只能解析到**文本值** +- 头像、图片消息这类图片字段,只能使用**固定图片**或**图片字典变量** +- 文本字典和图片字典**严格分型**,不要混用 +- 未知变量、空字典、已停用字典、类型不匹配,宿主会直接抛出校验失败 +- 图片变量必须是**单个 token**,例如 `{avatar}`,不能写成 `头像_{avatar}` + +如果你的模块也提供任务编辑器,建议: + +- 在 UI 中直接提示“支持 `{time}` 与 `{字典名}`” +- 文本输入框只展示文本字典变量 +- 图片输入框只展示图片字典变量 +- 让最终配置 JSON 只保存模板字符串 / 字典 token,不要把解析后的随机结果提前固化进配置 + +这样做的好处是: + +- 宿主统一管理字典内容,模块间可以复用同一份变量源 +- 后续扩展新变量 provider 时,模块通常不需要改协议 +- 计划任务、一次性任务、模块页面都能复用同一套解析规则 +### 7) 为“重新运行”提供专用构建器(适合复杂任务) 如果你的任务配置在运行过程中会写回运行态字段,或者重跑前需要清洗旧配置,建议额外实现 `IModuleTaskRerunBuilder`: @@ -666,3 +715,5 @@ public IEnumerable GetApis(ModuleHostContext context) + + diff --git a/src/TelegramPanel.Modules.MessageReport/MessageReportTaskEditor.razor b/src/TelegramPanel.Modules.MessageReport/MessageReportTaskEditor.razor new file mode 100644 index 0000000..52bcda7 --- /dev/null +++ b/src/TelegramPanel.Modules.MessageReport/MessageReportTaskEditor.razor @@ -0,0 +1,312 @@ +@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 new file mode 100644 index 0000000..1263b7f --- /dev/null +++ b/src/TelegramPanel.Modules.MessageReport/MessageReportTaskHandler.cs @@ -0,0 +1,312 @@ +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 new file mode 100644 index 0000000..4230f54 --- /dev/null +++ b/src/TelegramPanel.Modules.MessageReport/MessageReportTaskModels.cs @@ -0,0 +1,266 @@ +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 new file mode 100644 index 0000000..3195629 --- /dev/null +++ b/src/TelegramPanel.Modules.MessageReport/MessageReportTaskModule.cs @@ -0,0 +1,56 @@ +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 new file mode 100644 index 0000000..6a47ad2 --- /dev/null +++ b/src/TelegramPanel.Modules.MessageReport/MessageReportTaskRerunBuilder.cs @@ -0,0 +1,75 @@ +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 new file mode 100644 index 0000000..0739c3dd --- /dev/null +++ b/src/TelegramPanel.Modules.MessageReport/MessageReportTaskTelegramService.cs @@ -0,0 +1,419 @@ +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 new file mode 100644 index 0000000..f1c4730 --- /dev/null +++ b/src/TelegramPanel.Modules.MessageReport/TelegramPanel.Modules.MessageReport.csproj @@ -0,0 +1,23 @@ + + + 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 new file mode 100644 index 0000000..51a4aef --- /dev/null +++ b/src/TelegramPanel.Modules.MessageReport/_Imports.razor @@ -0,0 +1,4 @@ +@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 new file mode 100644 index 0000000..5fb95d4 --- /dev/null +++ b/src/TelegramPanel.Modules.MessageReport/manifest.json @@ -0,0 +1,11 @@ +{ + "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 new file mode 100644 index 0000000..9a2c644 --- /dev/null +++ b/src/TelegramPanel.Modules.MessageReportTask/Components/Dialogs/MessageReportTaskEditor.razor @@ -0,0 +1,315 @@ +@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 new file mode 100644 index 0000000..6c611e4 --- /dev/null +++ b/src/TelegramPanel.Modules.MessageReportTask/MessageReportTaskModule.cs @@ -0,0 +1,58 @@ +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 new file mode 100644 index 0000000..3b88951 --- /dev/null +++ b/src/TelegramPanel.Modules.MessageReportTask/Services/MessageReportTaskHandler.cs @@ -0,0 +1,429 @@ +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 new file mode 100644 index 0000000..2b0242d --- /dev/null +++ b/src/TelegramPanel.Modules.MessageReportTask/Services/MessageReportTaskModels.cs @@ -0,0 +1,86 @@ +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 new file mode 100644 index 0000000..8c17012 --- /dev/null +++ b/src/TelegramPanel.Modules.MessageReportTask/Services/MessageReportTaskRerunBuilder.cs @@ -0,0 +1,132 @@ +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 new file mode 100644 index 0000000..3a09881 --- /dev/null +++ b/src/TelegramPanel.Modules.MessageReportTask/Services/TelegramMessageReportService.cs @@ -0,0 +1,483 @@ +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 new file mode 100644 index 0000000..805a71e --- /dev/null +++ b/src/TelegramPanel.Modules.MessageReportTask/TelegramPanel.Modules.MessageReportTask.csproj @@ -0,0 +1,33 @@ + + + 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 new file mode 100644 index 0000000..1ae409c --- /dev/null +++ b/src/TelegramPanel.Modules.MessageReportTask/_Imports.razor @@ -0,0 +1,5 @@ +@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 new file mode 100644 index 0000000..7b1c89b --- /dev/null +++ b/src/TelegramPanel.Modules.MessageReportTask/manifest.json @@ -0,0 +1,13 @@ +{ + "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" + } +} diff --git a/src/TelegramPanel.Web/Components/Dialogs/BatchChangeAvatarDialog.razor b/src/TelegramPanel.Web/Components/Dialogs/BatchChangeAvatarDialog.razor new file mode 100644 index 0000000..ae05e07 --- /dev/null +++ b/src/TelegramPanel.Web/Components/Dialogs/BatchChangeAvatarDialog.razor @@ -0,0 +1,316 @@ +@namespace TelegramPanel.Web.Components.Dialogs +@inject AccountManagementService AccountManagement +@inject TelegramPanel.Core.Services.Telegram.AccountTelegramToolsService AccountTelegramTools +@inject TelegramPanel.Core.Services.AccountRiskService RiskService +@inject ImageAssetStorageService AssetStorage +@inject TemplateRenderingService TemplateRendering +@inject DataDictionaryService DictionaryService +@inject ISnackbar Snackbar +@inject IDialogService DialogService +@using Microsoft.AspNetCore.Components.Forms +@using TelegramPanel.Data.Entities + + + + + + 将对选中的账号批量修改头像。支持固定上传单个头像,或选择图片字典变量按字典读取方式为每个账号取图。 + + + 账号数量:@accounts.Count + + + 固定上传 + 图片字典变量 + + + @if (IsFixedSource) + { + + + + 选择头像图片 + + + + + @if (fixedAvatarBytes is { Length: > 0 }) + { + 已选择:@fixedAvatarFileName(@fixedAvatarBytes.Length bytes) + } + } + else + { + + -- 请选择图片字典 -- + @foreach (var item in imageDictionaryOptions) + { + @item.DisplayText + } + + + @if (imageDictionaryOptions.Count > 0) + { + 可用图片变量:@string.Join("、", imageDictionaryOptions.Select(x => $"{{{x.Name}}}")) + } + } + + @if (running || results.Count > 0) + { + + + + 进度:@processed / @accounts.Count + 成功:@success + 失败:@failed + @if (running) + { + + } + + + + + 账号 + 头像来源 + 结果 + 原因 + + + @context.Phone + @context.Source + + + @(context.Success ? "成功" : "失败") + + + @((context.Error ?? "").Trim()) + + + } + + + + 关闭 + + @if (running) + { + + 执行中... + } + else + { + 开始修改 + } + + + + +@code +{ + private const string AvatarSourceFixed = "fixed"; + private const string AvatarSourceDictionary = "dictionary"; + + [CascadingParameter] private MudDialogInstance MudDialog { get; set; } = default!; + [Parameter] public IReadOnlyList AccountIds { get; set; } = Array.Empty(); + + private List accounts = new(); + private List imageDictionaryOptions = new(); + + private string avatarSource = AvatarSourceFixed; + private string selectedImageDictionaryName = string.Empty; + private byte[]? fixedAvatarBytes; + private string fixedAvatarFileName = "avatar.jpg"; + + private bool running; + private int processed; + private int success; + private int failed; + private readonly List results = new(); + + private bool IsFixedSource => string.Equals(avatarSource, AvatarSourceFixed, StringComparison.OrdinalIgnoreCase); + + protected override async Task OnInitializedAsync() + { + var ids = AccountIds?.Distinct().Where(x => x > 0).ToList() ?? new List(); + if (ids.Count == 0) + return; + + var all = await AccountManagement.GetAllAccountsAsync(); + accounts = all.Where(a => ids.Contains(a.Id)).OrderBy(a => a.Id).ToList(); + + var dictionaries = await DictionaryService.GetAllAsync(); + imageDictionaryOptions = dictionaries + .Where(x => x.IsEnabled && string.Equals(x.Type, DataDictionaryTypes.Image, StringComparison.OrdinalIgnoreCase)) + .OrderBy(x => x.Name, StringComparer.OrdinalIgnoreCase) + .Select(x => new ImageDictionaryOption(x.Name, GetReadModeText(x.ReadMode))) + .ToList(); + } + + private async Task OnAvatarChanged(IReadOnlyList files) + { + var file = files?.FirstOrDefault(); + if (file == null) + { + fixedAvatarBytes = null; + fixedAvatarFileName = "avatar.jpg"; + return; + } + + try + { + const long maxBytes = 20 * 1024 * 1024; + await using var stream = file.OpenReadStream(maxBytes); + using var memory = new MemoryStream(); + await stream.CopyToAsync(memory); + fixedAvatarBytes = memory.ToArray(); + fixedAvatarFileName = string.IsNullOrWhiteSpace(file.Name) ? "avatar.jpg" : file.Name.Trim(); + avatarSource = AvatarSourceFixed; + } + catch (Exception ex) + { + fixedAvatarBytes = null; + fixedAvatarFileName = "avatar.jpg"; + Snackbar.Add($"读取头像失败:{ex.Message}", Severity.Error); + } + } + + private async Task Start() + { + if (running) + return; + + if (accounts.Count == 0) + { + Snackbar.Add("未选择账号", Severity.Warning); + return; + } + + if (IsFixedSource) + { + if (fixedAvatarBytes is not { Length: > 0 }) + { + Snackbar.Add("请先上传固定头像", Severity.Warning); + return; + } + } + else if (string.IsNullOrWhiteSpace(selectedImageDictionaryName)) + { + Snackbar.Add("请选择图片字典", Severity.Warning); + return; + } + + var batchCheck = RiskService.CheckBatchAccounts(accounts); + if (batchCheck.HasRiskyAccounts) + { + var parameters = new DialogParameters + { + ["Message"] = $"检测到 {batchCheck.RiskyCount} 个风险账号", + ["DetailedMessage"] = batchCheck.GetRiskySummary(), + ["ShowRecommendations"] = true, + ["ShowExcludeOption"] = true, + ["RiskyAccounts"] = batchCheck.RiskyAccounts.ToList() + }; + var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Large }; + var dialog = DialogService.Show("批量操作风控警告", parameters, options); + var result = await dialog.Result; + + if (result?.Canceled ?? true) + return; + + if (result.Data is TelegramPanel.Core.Services.RiskWarningAction action && action == TelegramPanel.Core.Services.RiskWarningAction.ExcludeRisky) + { + accounts = batchCheck.SafeAccounts.ToList(); + if (accounts.Count == 0) + { + Snackbar.Add("排除风险账号后无剩余账号", Severity.Info); + return; + } + } + } + + var sourceSummary = IsFixedSource ? "固定头像" : $"图片字典变量 {{{selectedImageDictionaryName}}}"; + bool? confirm = await DialogService.ShowMessageBox( + "确认修改", + $"将对 {accounts.Count} 个账号批量修改头像(来源:{sourceSummary})。是否继续?", + yesText: "继续", cancelText: "取消"); + + if (confirm != true) + return; + + running = true; + processed = 0; + success = 0; + failed = 0; + results.Clear(); + + try + { + foreach (var account in accounts) + { + string source; + bool ok; + string? err; + + try + { + if (IsFixedSource) + { + source = fixedAvatarFileName; + await using var stream = new MemoryStream(fixedAvatarBytes!, writable: false); + (ok, err) = await AccountTelegramTools.UpdateProfilePhotoAsync(account.Id, stream, fixedAvatarFileName, CancellationToken.None); + } + else + { + var token = $"{{{selectedImageDictionaryName}}}"; + var asset = await TemplateRendering.ResolveImageTemplateAsync(token, CancellationToken.None); + source = $"{token} → {asset.FileName}"; + await using var stream = await AssetStorage.OpenReadAsync(asset.AssetPath, CancellationToken.None); + (ok, err) = await AccountTelegramTools.UpdateProfilePhotoAsync(account.Id, stream, asset.FileName, CancellationToken.None); + } + } + catch (Exception ex) + { + source = IsFixedSource ? fixedAvatarFileName : $"{{{selectedImageDictionaryName}}}"; + ok = false; + err = ex.Message; + } + + processed++; + if (ok) + success++; + else + failed++; + + results.Add(new ResultRow(account.DisplayPhone, source, ok, err)); + StateHasChanged(); + } + + Snackbar.Add($"头像批量修改完成:成功 {success}/{accounts.Count},失败 {failed}/{accounts.Count}", failed == 0 ? Severity.Success : Severity.Warning); + } + catch (Exception ex) + { + Snackbar.Add($"执行失败:{ex.Message}", Severity.Error); + } + finally + { + running = false; + } + } + + private static string GetReadModeText(string? readMode) + { + return string.Equals(readMode, DataDictionaryReadModes.Queue, StringComparison.OrdinalIgnoreCase) + ? "轮询" + : "随机"; + } + + private void Cancel() => MudDialog.Close(); + + private sealed record ImageDictionaryOption(string Name, string ReadMode) + { + public string DisplayText => $"{Name}({ReadMode})"; + } + + private sealed record ResultRow(string Phone, string Source, bool Success, string? Error); +} diff --git a/src/TelegramPanel.Web/Components/Dialogs/BatchChangeNicknameDialog.razor b/src/TelegramPanel.Web/Components/Dialogs/BatchChangeNicknameDialog.razor index 947a127..9966e90 100644 --- a/src/TelegramPanel.Web/Components/Dialogs/BatchChangeNicknameDialog.razor +++ b/src/TelegramPanel.Web/Components/Dialogs/BatchChangeNicknameDialog.razor @@ -2,30 +2,38 @@ @inject AccountManagementService AccountManagement @inject TelegramPanel.Core.Services.Telegram.AccountTelegramToolsService AccountTelegramTools @inject TelegramPanel.Core.Services.AccountRiskService RiskService +@inject TemplateRenderingService TemplateRendering +@inject DataDictionaryService DictionaryService @inject ISnackbar Snackbar @inject IDialogService DialogService +@using System.Text.RegularExpressions @using TelegramPanel.Data.Entities - 将对选中的账号批量修改昵称。支持两种模式:单个“批量昵称”(可自动追加手机号后 4 位),或“昵称列表”(按行轮询分配)。 + 将对选中的账号批量修改昵称。支持两种模式:单个“批量昵称”或“昵称列表”。这两个输入都支持 `{time}` 和文本字典变量。 账号数量:@accounts.Count + HelperText="单昵称模式:例如“测试号{city}”。若填写了“昵称列表”,则优先使用昵称列表。" /> + HelperText="每行一个昵称模板;按顺序分配给已选账号;昵称用完后从头轮询。每行同样支持 `{time}` 和文本字典变量。" /> 例如:昵称列表只有 1 行或轮询后重复时,为避免重复昵称可追加手机号后 4 位。 + @if (textDictionaryNames.Count > 0) + { + 可用文本变量:@string.Join("、", textDictionaryNames.Select(x => $"{{{x}}}")) + } + @if (running || results.Count > 0) { @@ -82,7 +90,11 @@ [CascadingParameter] private MudDialogInstance MudDialog { get; set; } = default!; [Parameter] public IReadOnlyList AccountIds { get; set; } = Array.Empty(); + private static readonly Regex TemplateTokenRegex = new(@"\{(?[a-zA-Z0-9_]+)\}", RegexOptions.Compiled); + + private readonly HashSet supportedTextTokens = new(StringComparer.OrdinalIgnoreCase); private List accounts = new(); + private List textDictionaryNames = new(); private string baseNickname = ""; private bool appendPhoneLast4 = true; @@ -103,6 +115,17 @@ var all = await AccountManagement.GetAllAccountsAsync(); accounts = all.Where(a => ids.Contains(a.Id)).OrderBy(a => a.Id).ToList(); + + var dictionaries = await DictionaryService.GetAllAsync(); + textDictionaryNames = dictionaries + .Where(x => x.IsEnabled && string.Equals(x.Type, DataDictionaryTypes.Text, StringComparison.OrdinalIgnoreCase)) + .Select(x => x.Name) + .OrderBy(x => x, StringComparer.OrdinalIgnoreCase) + .ToList(); + + supportedTextTokens.Clear(); + foreach (var item in textDictionaryNames) + supportedTextTokens.Add(item); } private async Task Start() @@ -125,7 +148,13 @@ return; } - // 批量风控检查:修改昵称是敏感操作 + var templates = useList ? nicknameList : new List { baseNickname }; + if (!ValidateTemplates(templates, out var templateError)) + { + Snackbar.Add(templateError ?? "昵称模板不合法", Severity.Warning); + return; + } + var batchCheck = RiskService.CheckBatchAccounts(accounts); if (batchCheck.HasRiskyAccounts) { @@ -141,12 +170,11 @@ var dialog = DialogService.Show("批量操作风控警告", parameters, options); var result = await dialog.Result; - if (result.Canceled) - return; // 用户取消操作 + if (result?.Canceled ?? true) + return; if (result.Data is TelegramPanel.Core.Services.RiskWarningAction action && action == TelegramPanel.Core.Services.RiskWarningAction.ExcludeRisky) { - // 排除风险账号后继续 accounts = batchCheck.SafeAccounts.ToList(); if (accounts.Count == 0) { @@ -154,7 +182,6 @@ return; } } - // else: 用户选择继续所有账号 } bool? confirm = await DialogService.ShowMessageBox( @@ -174,35 +201,51 @@ try { var baseUsedCount = new Dictionary(StringComparer.Ordinal); - foreach (var a in accounts) + foreach (var account in accounts) { - var baseName = useList ? nicknameList[processed % nicknameList.Count] : baseNickname; + var nicknameTemplate = useList ? nicknameList[processed % nicknameList.Count] : baseNickname; + string resolvedNickname; + try + { + resolvedNickname = (await TemplateRendering.RenderTextTemplateAsync(nicknameTemplate, CancellationToken.None)).Trim(); + } + catch (Exception ex) + { + processed++; + failed++; + results.Add(new ResultRow(account.DisplayPhone, string.Empty, false, $"昵称模板解析失败:{ex.Message}")); + StateHasChanged(); + continue; + } var needAppend = appendPhoneLast4; if (useList) { - baseName = (baseName ?? string.Empty).Trim(); - baseUsedCount.TryGetValue(baseName, out var used); - baseUsedCount[baseName] = used + 1; + baseUsedCount.TryGetValue(resolvedNickname, out var used); + baseUsedCount[resolvedNickname] = used + 1; needAppend = appendPhoneLast4WhenDuplicate && used > 0; } - var newNickname = BuildNickname(baseName, a.Phone, needAppend); + var newNickname = BuildNickname(resolvedNickname, account.Phone, needAppend); var (ok, err) = await AccountTelegramTools.UpdateUserProfileAsync( - accountId: a.Id, + accountId: account.Id, nickname: newNickname, bio: null, cancellationToken: CancellationToken.None); processed++; - if (ok) success++; else failed++; - results.Add(new ResultRow(a.DisplayPhone, newNickname, ok, err)); + if (ok) + success++; + else + failed++; + + results.Add(new ResultRow(account.DisplayPhone, newNickname, ok, err)); if (ok) { - a.Nickname = newNickname; - await AccountManagement.UpdateAccountAsync(a); + account.Nickname = newNickname; + await AccountManagement.UpdateAccountAsync(account); } StateHasChanged(); @@ -220,6 +263,37 @@ } } + private bool ValidateTemplates(IEnumerable templates, out string? error) + { + foreach (var template in templates) + { + if (!ValidateTemplate(template, out error)) + return false; + } + + error = null; + return true; + } + + private bool ValidateTemplate(string? template, out string? error) + { + error = null; + foreach (Match token in TemplateTokenRegex.Matches(template ?? string.Empty)) + { + var tokenName = token.Groups["name"].Value; + if (string.Equals(tokenName, "time", StringComparison.OrdinalIgnoreCase)) + continue; + + if (!supportedTextTokens.Contains(tokenName)) + { + error = $"不支持的文本变量:{{{tokenName}}}。请使用 {{time}} 或已启用的文本字典变量。"; + return false; + } + } + + return true; + } + private static string BuildNickname(string baseName, string phone, bool appendLast4) { var name = (baseName ?? string.Empty).Trim(); @@ -266,4 +340,3 @@ private sealed record ResultRow(string Phone, string NewNickname, bool Success, string? Error); } - diff --git a/src/TelegramPanel.Web/Components/Dialogs/BatchChangeUsernameDialog.razor b/src/TelegramPanel.Web/Components/Dialogs/BatchChangeUsernameDialog.razor index 1e93b99..7178677 100644 --- a/src/TelegramPanel.Web/Components/Dialogs/BatchChangeUsernameDialog.razor +++ b/src/TelegramPanel.Web/Components/Dialogs/BatchChangeUsernameDialog.razor @@ -2,9 +2,10 @@ @inject AccountManagementService AccountManagement @inject TelegramPanel.Core.Services.Telegram.AccountTelegramToolsService AccountTelegramTools @inject TelegramPanel.Core.Services.AccountRiskService RiskService +@inject TemplateRenderingService TemplateRendering +@inject DataDictionaryService DictionaryService @inject ISnackbar Snackbar @inject IDialogService DialogService -@using System.Security.Cryptography @using System.Text.RegularExpressions @using TelegramPanel.Data.Entities @@ -12,19 +13,18 @@ - 将对选中的账号批量修改用户名(t.me/xxx)。用户名模板支持变量: - - {number} / {letter} 表示 1 位随机数字/字母;{number3} 表示 3 位随机数字;{letter4} 表示 4 位随机字母。可组合使用。 - - - 示例:test_{number3}{letter4} → test_123abcd - + 将对选中的账号批量修改用户名(t.me/xxx)。用户名模板支持 `{time}` 和文本字典变量;批量执行时建议使用文本字典提供不同值,避免重复。 账号数量:@accounts.Count + HelperText="请输入用户名模板(不要带开头的 at 符号)。例如:tg_{city}_{time}" /> + + @if (textDictionaryNames.Count > 0) + { + 可用文本变量:@string.Join("、", textDictionaryNames.Select(x => $"{{{x}}}")) + } @if (running || results.Count > 0) { @@ -82,11 +82,12 @@ [CascadingParameter] private MudDialogInstance MudDialog { get; set; } = default!; [Parameter] public IReadOnlyList AccountIds { get; set; } = Array.Empty(); - private static readonly Regex SupportedTokenRegex = new(@"\{(?number|nuber|letter)(?\d*)\}", RegexOptions.Compiled | RegexOptions.IgnoreCase); - private static readonly Regex AnyTokenRegex = new(@"\{[^}]*\}", RegexOptions.Compiled); + private static readonly Regex TemplateTokenRegex = new(@"\{(?[a-zA-Z0-9_]+)\}", RegexOptions.Compiled); private static readonly Regex UsernameRegex = new(@"^[A-Za-z][A-Za-z0-9_]{4,31}$", RegexOptions.Compiled); + private readonly HashSet supportedTextTokens = new(StringComparer.OrdinalIgnoreCase); private List accounts = new(); + private List textDictionaryNames = new(); private string usernameTemplate = ""; private bool running; @@ -103,6 +104,17 @@ var all = await AccountManagement.GetAllAccountsAsync(); accounts = all.Where(a => ids.Contains(a.Id)).OrderBy(a => a.Id).ToList(); + + var dictionaries = await DictionaryService.GetAllAsync(); + textDictionaryNames = dictionaries + .Where(x => x.IsEnabled && string.Equals(x.Type, DataDictionaryTypes.Text, StringComparison.OrdinalIgnoreCase)) + .Select(x => x.Name) + .OrderBy(x => x, StringComparer.OrdinalIgnoreCase) + .ToList(); + + supportedTextTokens.Clear(); + foreach (var item in textDictionaryNames) + supportedTextTokens.Add(item); } private async Task Start() @@ -123,19 +135,18 @@ return; } - if (accounts.Count > 1 && !SupportedTokenRegex.IsMatch(template)) + if (accounts.Count > 1 && !TemplateTokenRegex.IsMatch(template)) { - Snackbar.Add("批量修改多个账号时,用户名模板需要包含至少一个随机变量(例如 {number3} / {letter4})", Severity.Warning); + Snackbar.Add("批量修改多个账号时,用户名模板至少需要包含一个变量(例如 {time} 或 {username_pool})", Severity.Warning); return; } - if (!ValidateTemplateTokens(template, out var templateErr)) + if (!ValidateTemplate(template, out var templateErr)) { Snackbar.Add(templateErr ?? "用户名模板不合法", Severity.Warning); return; } - // 批量风控检查:修改用户名是敏感操作 var batchCheck = RiskService.CheckBatchAccounts(accounts); if (batchCheck.HasRiskyAccounts) { @@ -151,7 +162,7 @@ var dialog = DialogService.Show("批量操作风控警告", parameters, options); var result = await dialog.Result; - if (result.Canceled) + if (result?.Canceled ?? true) return; if (result.Data is TelegramPanel.Core.Services.RiskWarningAction action && action == TelegramPanel.Core.Services.RiskWarningAction.ExcludeRisky) @@ -182,30 +193,48 @@ try { var generated = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var a in accounts) + foreach (var account in accounts) { - if (!TryGenerateUniqueUsername(template, generated, out var newUsername, out var genErr)) + string renderedTemplate; + try + { + renderedTemplate = await TemplateRendering.RenderTextTemplateAsync(template, CancellationToken.None); + } + catch (Exception ex) { processed++; failed++; - results.Add(new ResultRow(a.DisplayPhone, "", false, genErr ?? "生成用户名失败")); + results.Add(new ResultRow(account.DisplayPhone, string.Empty, false, $"用户名模板解析失败:{ex.Message}")); + StateHasChanged(); + continue; + } + + if (!TryPrepareUsername(renderedTemplate, generated, out var newUsername, out var prepareError)) + { + processed++; + failed++; + results.Add(new ResultRow(account.DisplayPhone, newUsername, false, prepareError ?? "生成用户名失败")); StateHasChanged(); continue; } var (ok, err, username) = await AccountTelegramTools.UpdateUsernameAsync( - accountId: a.Id, + accountId: account.Id, username: newUsername, cancellationToken: CancellationToken.None); processed++; - if (ok) success++; else failed++; - results.Add(new ResultRow(a.DisplayPhone, newUsername, ok, err)); + if (ok) + success++; + else + failed++; + + results.Add(new ResultRow(account.DisplayPhone, newUsername, ok, err)); if (ok) { - a.Username = username; - await AccountManagement.UpdateAccountAsync(a); + account.Username = username; + await AccountManagement.UpdateAccountAsync(account); } StateHasChanged(); @@ -223,27 +252,18 @@ } } - private static bool ValidateTemplateTokens(string template, out string? error) + private bool ValidateTemplate(string? template, out string? error) { error = null; - - var tokens = AnyTokenRegex.Matches(template); - foreach (Match t in tokens) + foreach (Match token in TemplateTokenRegex.Matches(template ?? string.Empty)) { - if (!SupportedTokenRegex.IsMatch(t.Value)) - { - error = $"不支持的变量:{t.Value}。仅支持 {{number}}/{{letter}}/{{numberN}}/{{letterN}}。"; - return false; - } - - var m = SupportedTokenRegex.Match(t.Value); - var lenStr = m.Groups["len"].Value; - if (string.IsNullOrWhiteSpace(lenStr)) + var tokenName = token.Groups["name"].Value; + if (string.Equals(tokenName, "time", StringComparison.OrdinalIgnoreCase)) continue; - if (!int.TryParse(lenStr, out var len) || len <= 0 || len > 32) + if (!supportedTextTokens.Contains(tokenName)) { - error = $"变量长度不合法:{t.Value}。长度需为 1-32。"; + error = $"不支持的文本变量:{{{tokenName}}}。请使用 {{time}} 或已启用的文本字典变量。"; return false; } } @@ -251,67 +271,28 @@ return true; } - private static bool TryGenerateUniqueUsername( - string template, + private static bool TryPrepareUsername( + string templateResult, HashSet reserved, out string username, out string? error) { - username = ""; + username = (templateResult ?? string.Empty).Trim().TrimStart('@'); error = null; - for (var i = 0; i < 25; i++) + if (!UsernameRegex.IsMatch(username)) { - var candidate = ExpandTemplate(template); - candidate = (candidate ?? string.Empty).Trim().TrimStart('@'); - - if (!UsernameRegex.IsMatch(candidate)) - { - error = "生成的用户名不符合 Telegram 规则:仅允许字母/数字/下划线,长度 5-32,且必须以字母开头。"; - return false; - } - - if (reserved.Add(candidate)) - { - username = candidate; - return true; - } + error = "生成的用户名不符合 Telegram 规则:仅允许字母/数字/下划线,长度 5-32,且必须以字母开头。"; + return false; } - error = "生成的用户名在本次批量操作中重复次数过多,请增加随机位数(例如 {number6} / {letter6})。"; - return false; - } - - private static string ExpandTemplate(string template) - { - return SupportedTokenRegex.Replace(template, m => + if (!reserved.Add(username)) { - var kind = (m.Groups["kind"].Value ?? string.Empty).ToLowerInvariant(); - if (kind == "nuber") - kind = "number"; - var lenStr = m.Groups["len"].Value; - var len = string.IsNullOrWhiteSpace(lenStr) ? 1 : int.Parse(lenStr); + error = "本次批量操作生成了重复用户名,请调整文本字典内容或模板。"; + return false; + } - return kind == "number" - ? RandomDigits(len) - : RandomLetters(len); - }); - } - - private static string RandomDigits(int length) - { - var chars = new char[length]; - for (var i = 0; i < length; i++) - chars[i] = (char)('0' + RandomNumberGenerator.GetInt32(0, 10)); - return new string(chars); - } - - private static string RandomLetters(int length) - { - var chars = new char[length]; - for (var i = 0; i < length; i++) - chars[i] = (char)('a' + RandomNumberGenerator.GetInt32(0, 26)); - return new string(chars); + return true; } private void Cancel() => MudDialog.Close(); diff --git a/src/TelegramPanel.Web/Components/Pages/AccountImport.razor b/src/TelegramPanel.Web/Components/Pages/AccountImport.razor index ad04068..6271ad5 100644 --- a/src/TelegramPanel.Web/Components/Pages/AccountImport.razor +++ b/src/TelegramPanel.Web/Components/Pages/AccountImport.razor @@ -213,6 +213,7 @@ 批量换绑邮箱(找回+登录)(Cloud Mail)(已选) 批量修改分类(已选) 批量改昵称(已选) + 批量改头像(已选) 批量改用户名(已选) 批量改Bio(已选) @@ -682,6 +683,29 @@ await ReloadImportedAccountsAsync(); } + private async Task OpenBatchChangeAvatar() + { + if (Busy) + return; + + var ids = selectedAccounts.Select(a => a.Id).Distinct().ToList(); + if (ids.Count == 0) + { + Snackbar.Add("请先选择账号", Severity.Info); + return; + } + + var parameters = new DialogParameters + { + ["AccountIds"] = ids + }; + var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Medium, FullWidth = true }; + var dialog = DialogService.Show("批量修改头像", parameters, options)!; + var result = await dialog.Result; + if (result is { Canceled: false }) + await ReloadImportedAccountsAsync(); + } + private async Task OpenBatchChangeUsername() { if (Busy) diff --git a/src/TelegramPanel.Web/Components/Pages/Accounts.razor b/src/TelegramPanel.Web/Components/Pages/Accounts.razor index 4470f7c..c0e7ef8 100644 --- a/src/TelegramPanel.Web/Components/Pages/Accounts.razor +++ b/src/TelegramPanel.Web/Components/Pages/Accounts.razor @@ -84,6 +84,7 @@ 批量踢出所有其他设备(已选) 批量修改分类(已选) 批量改昵称(已选) + 批量改头像(已选) 批量改用户名(已选) 批量改Bio(已选) 删除已选 @@ -522,6 +523,29 @@ await ReloadTable(); } + private async Task OpenBatchChangeAvatar() + { + if (loading) + return; + + var ids = selectedAccounts.Select(a => a.Id).Distinct().ToList(); + if (ids.Count == 0) + { + Snackbar.Add("请先选择账号", Severity.Info); + return; + } + + var parameters = new DialogParameters + { + ["AccountIds"] = ids + }; + var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Medium, FullWidth = true }; + var dialog = DialogService.Show("批量修改头像", parameters, options)!; + var result = await dialog.Result; + if (result is { Canceled: false }) + await ReloadTable(); + } + private async Task OpenBatchChangeUsername() { if (loading) diff --git a/src/TelegramPanel.Web/Components/Pages/DataDictionaries.razor b/src/TelegramPanel.Web/Components/Pages/DataDictionaries.razor index 6ffa13b..ea7872d 100644 --- a/src/TelegramPanel.Web/Components/Pages/DataDictionaries.razor +++ b/src/TelegramPanel.Web/Components/Pages/DataDictionaries.razor @@ -8,6 +8,12 @@ 数据字典管理 + + 当前系统内置变量:{time},格式为 yyyyMMddHHmmss,例如 20260310143059。 + 它不是 Unix 时间戳,而是可直接阅读的时间字符串。 + 文本字段通常支持 {time} 和 {字典名};图片字段通常只能使用单个图片字典变量,例如 {avatar}。 + + diff --git a/src/TelegramPanel.Web/Services/TemplateRenderingService.cs b/src/TelegramPanel.Web/Services/TemplateRenderingService.cs index 26d9bab..b56c54e 100644 --- a/src/TelegramPanel.Web/Services/TemplateRenderingService.cs +++ b/src/TelegramPanel.Web/Services/TemplateRenderingService.cs @@ -1,5 +1,6 @@ using System.Text; using System.Text.RegularExpressions; +using TelegramPanel.Data.Entities; namespace TelegramPanel.Web.Services; @@ -16,6 +17,47 @@ public sealed class TemplateRenderingService _dataDictionaryService = dataDictionaryService; } + public IReadOnlyList ExtractTokenNames(string? template) + { + if (string.IsNullOrWhiteSpace(template)) + return Array.Empty(); + + var names = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (Match match in TokenRegex.Matches(template)) + { + if (!match.Success) + continue; + + var tokenName = (match.Groups["name"].Value ?? string.Empty).Trim(); + if (tokenName.Length == 0 || !seen.Add(tokenName)) + continue; + + names.Add(tokenName); + } + + return names; + } + + public async Task ValidateTextTemplateAsync(string? template, CancellationToken cancellationToken = default) + { + foreach (var tokenName in ExtractTokenNames(template)) + { + if (string.Equals(tokenName, "time", StringComparison.OrdinalIgnoreCase)) + continue; + + await EnsureDictionaryAvailableAsync(tokenName, DataDictionaryTypes.Text, cancellationToken); + } + } + + public async Task ValidateImageTemplateAsync(string? tokenExpression, CancellationToken cancellationToken = default) + { + var tokenName = ExtractSingleTokenName(tokenExpression) + ?? throw new InvalidOperationException("图片变量必须是单个字典变量,例如 {avatar}"); + + await EnsureDictionaryAvailableAsync(tokenName, DataDictionaryTypes.Image, cancellationToken); + } + public async Task RenderTextTemplateAsync(string template, CancellationToken cancellationToken = default) { template = template ?? string.Empty; @@ -75,4 +117,33 @@ public sealed class TemplateRenderingService return await _dataDictionaryService.ResolveTextValueAsync(tokenName, cancellationToken); } + + private async Task EnsureDictionaryAvailableAsync(string tokenName, string expectedType, CancellationToken cancellationToken) + { + var dictionary = await _dataDictionaryService.GetByNameAsync(tokenName, cancellationToken) + ?? throw new InvalidOperationException($"未找到变量:{{{tokenName}}}"); + + if (!dictionary.IsEnabled) + throw new InvalidOperationException($"变量已停用:{{{tokenName}}}"); + + if (!string.Equals(dictionary.Type, expectedType, StringComparison.OrdinalIgnoreCase)) + { + var expectedName = string.Equals(expectedType, DataDictionaryTypes.Image, StringComparison.OrdinalIgnoreCase) + ? "图片字典" + : "文本字典"; + throw new InvalidOperationException($"变量类型不匹配:{{{tokenName}}} 需要使用{expectedName}"); + } + + var hasAvailableItem = string.Equals(expectedType, DataDictionaryTypes.Image, StringComparison.OrdinalIgnoreCase) + ? dictionary.Items.Any(x => x.IsEnabled && !string.IsNullOrWhiteSpace(x.AssetPath)) + : dictionary.Items.Any(x => x.IsEnabled && !string.IsNullOrWhiteSpace(x.TextValue)); + + if (!hasAvailableItem) + { + var valueName = string.Equals(expectedType, DataDictionaryTypes.Image, StringComparison.OrdinalIgnoreCase) + ? "图片" + : "内容"; + throw new InvalidOperationException($"变量没有可用{valueName}:{{{tokenName}}}"); + } + } }