feat: 更新数据字典与账号批量资料功能

This commit is contained in:
meoacgx committed 2026-03-10 01:19:38 +08:00
1 parent c2a680d5c4
commit 7f821fb96d
27 files changed
+3707 -111

No files matched your search

+19 -1
View File
@@ -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
+52 -1
View File
@@ -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<TemplateRenderingService>();
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<ModuleApiTypeDefinition> GetApis(ModuleHostContext context)
@@ -0,0 +1,312 @@
@using System.Text.Json
@using TelegramPanel.Core.Services
@using TelegramPanel.Data.Entities
@inject AccountCategoryManagementService CategoryManagement
@inject AccountManagementService AccountManagement
<MudStack Spacing="2">
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Dense="true">
该任务会按账号分类轮流举报指定消息。<b>MaxReports=0</b> 表示持续运行直到你在任务中心手动暂停/停止;如果在外层选择 <b>Cron 计划</b>,建议配合有限次数使用。
</MudAlert>
@if (loading)
{
<MudProgressCircular Indeterminate="true" Color="Color.Primary" Size="Size.Small" />
<MudText Typo="Typo.caption">正在加载账号分类...</MudText>
}
else
{
<MudSelect T="int"
Label="执行账号分类(可多选)"
Variant="Variant.Outlined"
Dense="true"
MultiSelection="true"
SelectedValues="selectedCategoryIds"
SelectedValuesChanged="OnSelectedCategoriesChanged"
HelperText="会从所有选中分类中抽取可用账号轮流执行举报。">
@foreach (var category in categories)
{
<MudSelectItem Value="@category.Id">@category.Name</MudSelectItem>
}
</MudSelect>
<MudText Typo="Typo.caption" Color="Color.Secondary">
当前选中分类下可用账号数:@selectedAccountCount
</MudText>
<MudTextField @bind-Value="messageReferencesText" @bind-Value:after="OnInputsChanged"
Label="消息链接 / 消息引用(每行一个)"
Variant="Variant.Outlined"
Lines="6"
Immediate="true"
HelperText="支持:https://t.me/username/123、t.me/c/1234567890/456、用户名/789、-1001234567890/321" />
<MudGrid>
<MudItem xs="12" md="4">
<MudNumericField T="int" @bind-Value="delayMinSeconds" @bind-Value:after="OnInputsChanged"
Label="最小间隔(秒)" Variant="Variant.Outlined" Min="0" Max="600" />
</MudItem>
<MudItem xs="12" md="4">
<MudNumericField T="int" @bind-Value="delayMaxSeconds" @bind-Value:after="OnInputsChanged"
Label="最大间隔(秒)" Variant="Variant.Outlined" Min="0" Max="600" />
</MudItem>
<MudItem xs="12" md="4">
<MudNumericField T="int" @bind-Value="maxReports" @bind-Value:after="OnInputsChanged"
Label="最多举报次数" Variant="Variant.Outlined" Min="0" Max="1000000"
HelperText="0 表示持续执行直到手动停止。" />
</MudItem>
</MudGrid>
<MudSelect T="string" @bind-Value="reportType" @bind-Value:after="OnInputsChanged"
Label="举报类型偏好" Variant="Variant.Outlined" Dense="true">
<MudSelectItem Value="@MessageReportTaskReportTypes.Spam">垃圾/广告</MudSelectItem>
<MudSelectItem Value="@MessageReportTaskReportTypes.Violence">暴力</MudSelectItem>
<MudSelectItem Value="@MessageReportTaskReportTypes.Pornography">色情/成人</MudSelectItem>
<MudSelectItem Value="@MessageReportTaskReportTypes.ChildAbuse">儿童/未成年相关</MudSelectItem>
<MudSelectItem Value="@MessageReportTaskReportTypes.Copyright">版权/侵权</MudSelectItem>
<MudSelectItem Value="@MessageReportTaskReportTypes.PersonalDetails">隐私/个人信息</MudSelectItem>
<MudSelectItem Value="@MessageReportTaskReportTypes.IllegalDrugs">毒品/违禁药</MudSelectItem>
<MudSelectItem Value="@MessageReportTaskReportTypes.Fake">诈骗/冒充/虚假</MudSelectItem>
<MudSelectItem Value="@MessageReportTaskReportTypes.Other">其他</MudSelectItem>
<MudSelectItem Value="@MessageReportTaskReportTypes.Auto">自动(取 Telegram 返回的首项)</MudSelectItem>
</MudSelect>
<MudTextField @bind-Value="optionKeywordsText" @bind-Value:after="OnInputsChanged"
Label="举报选项匹配关键词(可选,每行一个)"
Variant="Variant.Outlined"
Lines="3"
Immediate="true"
HelperText="当 Telegram 返回动态举报菜单时,会优先按“举报类型偏好 + 这里的关键词”去匹配选项文本。" />
<MudTextField @bind-Value="commentsText" @bind-Value:after="OnInputsChanged"
Label="举报文案(可选,每行一个)"
Variant="Variant.Outlined"
Lines="4"
Immediate="true"
HelperText="仅当 Telegram 要求补充举报说明时才会使用;留空则只在“可跳过评论”时继续。" />
<MudSelect T="string" @bind-Value="commentMode" @bind-Value:after="OnInputsChanged"
Label="举报文案取值模式" Variant="Variant.Outlined" Dense="true">
<MudSelectItem Value="@MessageReportTaskModes.Queue">队列循环</MudSelectItem>
<MudSelectItem Value="@MessageReportTaskModes.Random">随机</MudSelectItem>
</MudSelect>
}
</MudStack>
@code {
[Parameter] public ModuleTaskDraft Draft { get; set; }
[Parameter] public EventCallback<ModuleTaskDraft> DraftChanged { get; set; }
[Parameter] public string? InitialConfigJson { get; set; }
private List<AccountCategory> categories = new();
private List<Account> activeAccounts = new();
private bool loading = true;
private bool initializedFromConfig;
private IEnumerable<int> selectedCategoryIds = Array.Empty<int>();
private string messageReferencesText = string.Empty;
private int delayMinSeconds = 15;
private int delayMaxSeconds = 45;
private int maxReports;
private string reportType = MessageReportTaskReportTypes.Spam;
private string optionKeywordsText = string.Empty;
private string commentsText = string.Empty;
private string commentMode = MessageReportTaskModes.Queue;
private int runtimeCompletedAttempts;
private int runtimeFailedAttempts;
private int runtimeAccountCursor;
private int runtimeMessageCursor;
private int runtimeCommentCursor;
private List<MessageReportTaskRuntimeFailure> runtimeRecentFailures = new();
private int selectedAccountCount => activeAccounts.Count(x =>
x.IsActive
&& x.UserId > 0
&& x.CategoryId.HasValue
&& selectedCategoryIds.Contains(x.CategoryId.Value)
&& x.Category?.ExcludeFromOperations != true);
protected override async Task OnInitializedAsync()
{
ApplyInitialConfigIfNeeded();
try
{
var categoriesTask = CategoryManagement.GetAllCategoriesAsync();
var accountsTask = AccountManagement.GetAllAccountsAsync();
await Task.WhenAll(categoriesTask, accountsTask);
categories = categoriesTask.Result
.Where(x => !x.ExcludeFromOperations)
.OrderBy(x => x.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
activeAccounts = accountsTask.Result.ToList();
if (!selectedCategoryIds.Any())
{
var firstCategoryId = categories.FirstOrDefault()?.Id ?? 0;
selectedCategoryIds = firstCategoryId > 0 ? new[] { firstCategoryId } : Array.Empty<int>();
}
}
finally
{
loading = false;
await PushDraftAsync();
}
}
private void ApplyInitialConfigIfNeeded()
{
if (initializedFromConfig)
return;
initializedFromConfig = true;
var raw = (InitialConfigJson ?? string.Empty).Trim();
if (raw.Length == 0)
return;
try
{
var cfg = JsonSerializer.Deserialize<MessageReportTaskConfig>(raw);
if (cfg == null)
return;
var categoryIds = MessageReportTaskInputHelper.NormalizeCategoryIds(cfg.CategoryIds, cfg.CategoryId);
if (categoryIds.Count > 0)
selectedCategoryIds = categoryIds;
messageReferencesText = string.Join(Environment.NewLine, cfg.MessageReferences ?? new List<string>());
delayMinSeconds = ConvertMillisecondsToSeconds(cfg.DelayMinMs);
delayMaxSeconds = ConvertMillisecondsToSeconds(cfg.DelayMaxMs);
maxReports = Math.Max(0, cfg.MaxReports);
reportType = MessageReportTaskReportTypes.Normalize(cfg.ReportType);
optionKeywordsText = string.Join(Environment.NewLine, cfg.OptionKeywords ?? new List<string>());
commentsText = string.Join(Environment.NewLine, cfg.Comments ?? new List<string>());
commentMode = MessageReportTaskModes.Normalize(cfg.CommentMode);
runtimeCompletedAttempts = Math.Max(0, cfg.CompletedAttempts);
runtimeFailedAttempts = Math.Max(0, cfg.FailedAttempts);
runtimeAccountCursor = Math.Max(0, cfg.AccountCursor);
runtimeMessageCursor = Math.Max(0, cfg.MessageCursor);
runtimeCommentCursor = Math.Max(0, cfg.CommentCursor);
runtimeRecentFailures = cfg.RecentFailures?.TakeLast(20).ToList() ?? new List<MessageReportTaskRuntimeFailure>();
}
catch
{
}
}
private async Task OnSelectedCategoriesChanged(IEnumerable<int> values)
{
selectedCategoryIds = MessageReportTaskInputHelper.NormalizeCategoryIds(values, 0);
await OnInputsChanged();
}
private async Task OnInputsChanged()
{
await PushDraftAsync();
}
private async Task PushDraftAsync()
{
var selectedIds = MessageReportTaskInputHelper.NormalizeCategoryIds(selectedCategoryIds, 0);
var selectedCategories = categories.Where(x => selectedIds.Contains(x.Id)).ToList();
var messageReferences = MessageReportTaskInputHelper.ParseMultilineText(messageReferencesText, distinct: true);
var optionKeywords = MessageReportTaskInputHelper.ParseMultilineText(optionKeywordsText, distinct: true);
var comments = MessageReportTaskInputHelper.ParseMultilineText(commentsText, distinct: false);
if (loading)
{
await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "账号分类加载中"));
return;
}
if (categories.Count == 0)
{
await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "没有可用账号分类(或都被标记为排除操作)"));
return;
}
if (selectedCategories.Count == 0)
{
await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "请至少选择一个执行账号分类"));
return;
}
if (selectedAccountCount <= 0)
{
await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "所选分类下没有可用执行账号"));
return;
}
if (messageReferences.Count == 0)
{
await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "请至少填写一个消息引用"));
return;
}
foreach (var reference in messageReferences)
{
if (!MessageReportTaskInputHelper.TryParseMessageReference(reference, out _, out var error))
{
await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, $"消息引用无效:{reference}({error})"));
return;
}
}
if (delayMinSeconds < 0 || delayMaxSeconds < 0)
{
await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "间隔不能为负数"));
return;
}
if (delayMaxSeconds < delayMinSeconds)
{
await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "最大间隔不能小于最小间隔"));
return;
}
if (maxReports < 0)
{
await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "最多举报次数不能小于 0"));
return;
}
var cfg = new MessageReportTaskConfig
{
CategoryId = selectedCategories[0].Id,
CategoryName = selectedCategories[0].Name,
CategoryIds = selectedCategories.Select(x => x.Id).ToList(),
CategoryNames = selectedCategories.Select(x => x.Name).ToList(),
MessageReferences = messageReferences,
DelayMinMs = delayMinSeconds * 1000,
DelayMaxMs = delayMaxSeconds * 1000,
MaxReports = maxReports,
ReportType = MessageReportTaskReportTypes.Normalize(reportType),
OptionKeywords = optionKeywords,
Comments = comments,
CommentMode = MessageReportTaskModes.Normalize(commentMode),
CompletedAttempts = runtimeCompletedAttempts,
FailedAttempts = runtimeFailedAttempts,
AccountCursor = runtimeAccountCursor,
MessageCursor = runtimeMessageCursor,
CommentCursor = runtimeCommentCursor,
Canceled = false,
Error = null,
RecentFailures = runtimeRecentFailures.ToList()
};
var total = maxReports > 0 ? maxReports : 0;
var json = JsonSerializer.Serialize(cfg, new JsonSerializerOptions { WriteIndented = true });
await DraftChanged.InvokeAsync(new ModuleTaskDraft(total, json, true, null));
}
private static int ConvertMillisecondsToSeconds(int milliseconds)
{
if (milliseconds <= 0)
return 0;
return (int)Math.Ceiling(milliseconds / 1000d);
}
}
@@ -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<ILogger<MessageReportTaskHandler>>();
var taskManagement = host.Services.GetRequiredService<BatchTaskManagementService>();
var accountManagement = host.Services.GetRequiredService<AccountManagementService>();
var telegramService = host.Services.GetRequiredService<MessageReportTaskTelegramService>();
var config = DeserializeConfig(host.Config);
ValidateAndNormalizeConfig(config);
var selectedCategoryIds = MessageReportTaskInputHelper.NormalizeCategoryIds(config.CategoryIds, config.CategoryId).ToHashSet();
var allAccounts = (await accountManagement.GetAllAccountsAsync())
.Where(x => x.IsActive && x.UserId > 0 && x.Category?.ExcludeFromOperations != true)
.Where(x => x.CategoryId.HasValue && selectedCategoryIds.Contains(x.CategoryId.Value))
.OrderBy(x => x.Id)
.ToList();
if (allAccounts.Count == 0)
{
config.Error = "所选分类下没有可用执行账号";
await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(config));
throw new InvalidOperationException(config.Error);
}
var parsedMessageReferences = config.MessageReferences
.Select(raw =>
{
if (!MessageReportTaskInputHelper.TryParseMessageReference(raw, out var parsed, out var error) || parsed == null)
throw new InvalidOperationException($"消息引用无效:{raw}({error})");
return parsed;
})
.ToList();
var completed = Math.Max(0, config.CompletedAttempts);
var failed = Math.Max(0, config.FailedAttempts);
var accountCursor = NormalizeCursor(config.AccountCursor, allAccounts.Count);
var messageCursor = NormalizeCursor(config.MessageCursor, parsedMessageReferences.Count);
var commentCursor = Math.Max(0, config.CommentCursor);
var resolvedCache = new Dictionary<string, ResolvedMessageTarget>(StringComparer.OrdinalIgnoreCase);
config.Canceled = false;
config.Error = null;
config.CompletedAttempts = completed;
config.FailedAttempts = failed;
config.AccountCursor = accountCursor;
config.MessageCursor = messageCursor;
config.CommentCursor = commentCursor;
await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(config));
await host.UpdateProgressAsync(completed, failed, cancellationToken);
while (!cancellationToken.IsCancellationRequested)
{
cancellationToken.ThrowIfCancellationRequested();
if (!await host.IsStillRunningAsync(cancellationToken))
{
config.Canceled = true;
config.Error = null;
config.CompletedAttempts = completed;
config.FailedAttempts = failed;
config.AccountCursor = accountCursor;
config.MessageCursor = messageCursor;
config.CommentCursor = commentCursor;
await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(config));
return;
}
if (config.MaxReports > 0 && completed >= config.MaxReports)
break;
var account = allAccounts[SelectIndex(allAccounts.Count, ref accountCursor)];
var messageReference = parsedMessageReferences[SelectIndex(parsedMessageReferences.Count, ref messageCursor)];
var cacheKey = $"{account.Id}:{messageReference.CanonicalId}";
string? failureReason = null;
try
{
if (!resolvedCache.TryGetValue(cacheKey, out var resolvedTarget))
{
var resolved = await telegramService.ResolveMessageReferenceAsync(account.Id, messageReference.Raw, cancellationToken);
if (!resolved.Success || resolved.Target == null)
{
failureReason = NormalizeReason(resolved.Error);
}
else
{
resolvedTarget = resolved.Target;
resolvedCache[cacheKey] = resolvedTarget;
}
}
if (failureReason == null && resolvedCache.TryGetValue(cacheKey, out var cachedTarget))
{
var report = await telegramService.ReportMessageAsync(account.Id, cachedTarget, config, commentCursor, cancellationToken);
commentCursor = Math.Max(0, report.NextCommentCursor);
if (!string.IsNullOrWhiteSpace(report.SelectedOptionText))
logger.LogDebug("Message report option selected: {OptionText}", report.SelectedOptionText);
if (!report.Success)
{
failureReason = NormalizeReason(report.Error);
if (LooksLikePeerInvalid(report.Error))
resolvedCache.Remove(cacheKey);
}
}
}
catch (Exception ex)
{
logger.LogWarning(ex, "Message report task iteration failed (taskId={TaskId}, accountId={AccountId}, message={Message})", host.TaskId, account.Id, messageReference.CanonicalId);
var mapped = AccountTelegramToolsService.MapTelegramException(ex);
failureReason = NormalizeReason(string.IsNullOrWhiteSpace(mapped.details) ? mapped.summary : $"{mapped.summary}:{mapped.details}");
}
completed++;
if (!string.IsNullOrWhiteSpace(failureReason))
{
failed++;
AddFailure(config, account, messageReference.Raw, failureReason!);
}
config.Canceled = false;
config.Error = null;
config.CompletedAttempts = completed;
config.FailedAttempts = failed;
config.AccountCursor = accountCursor;
config.MessageCursor = messageCursor;
config.CommentCursor = commentCursor;
await host.UpdateProgressAsync(completed, failed, cancellationToken);
await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(config));
if (config.MaxReports > 0 && completed >= config.MaxReports)
break;
if (!await DelayAsync(host, config.DelayMinMs, config.DelayMaxMs, cancellationToken))
{
config.Canceled = true;
config.Error = null;
config.CompletedAttempts = completed;
config.FailedAttempts = failed;
config.AccountCursor = accountCursor;
config.MessageCursor = messageCursor;
config.CommentCursor = commentCursor;
await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(config));
return;
}
}
config.Canceled = false;
config.Error = null;
config.CompletedAttempts = completed;
config.FailedAttempts = failed;
config.AccountCursor = accountCursor;
config.MessageCursor = messageCursor;
config.CommentCursor = commentCursor;
await host.UpdateProgressAsync(completed, failed, cancellationToken);
await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(config));
}
private static MessageReportTaskConfig DeserializeConfig(string? rawConfig)
{
var raw = (rawConfig ?? string.Empty).Trim();
if (raw.Length == 0)
throw new InvalidOperationException("任务配置为空");
try
{
return JsonSerializer.Deserialize<MessageReportTaskConfig>(raw)
?? throw new InvalidOperationException("任务配置解析结果为空");
}
catch (Exception ex)
{
throw new InvalidOperationException($"任务配置 JSON 无效:{ex.Message}");
}
}
private static void ValidateAndNormalizeConfig(MessageReportTaskConfig config)
{
config.CategoryIds = MessageReportTaskInputHelper.NormalizeCategoryIds(config.CategoryIds, config.CategoryId);
config.CategoryNames = MessageReportTaskInputHelper.NormalizeCategoryNames(config.CategoryNames, config.CategoryName);
config.MessageReferences = MessageReportTaskInputHelper.NormalizeLines(config.MessageReferences, distinct: true);
config.OptionKeywords = MessageReportTaskInputHelper.NormalizeLines(config.OptionKeywords, distinct: true);
config.Comments = MessageReportTaskInputHelper.NormalizeLines(config.Comments);
config.CommentMode = MessageReportTaskModes.Normalize(config.CommentMode);
config.ReportType = MessageReportTaskReportTypes.Normalize(config.ReportType);
config.DelayMinMs = Math.Clamp(config.DelayMinMs, 0, 600000);
config.DelayMaxMs = Math.Clamp(config.DelayMaxMs, config.DelayMinMs, 600000);
config.MaxReports = Math.Max(0, config.MaxReports);
config.CompletedAttempts = Math.Max(0, config.CompletedAttempts);
config.FailedAttempts = Math.Max(0, config.FailedAttempts);
config.AccountCursor = Math.Max(0, config.AccountCursor);
config.MessageCursor = Math.Max(0, config.MessageCursor);
config.CommentCursor = Math.Max(0, config.CommentCursor);
config.RecentFailures ??= new List<MessageReportTaskRuntimeFailure>();
if (config.CategoryIds.Count == 0)
throw new InvalidOperationException("请至少选择一个执行账号分类");
if (config.MessageReferences.Count == 0)
throw new InvalidOperationException("请至少填写一个消息引用");
foreach (var messageReference in config.MessageReferences)
{
if (!MessageReportTaskInputHelper.TryParseMessageReference(messageReference, out _, out var error))
throw new InvalidOperationException($"消息引用无效:{messageReference}({error})");
}
}
private static int SelectIndex(int count, ref int cursor)
{
if (count <= 0)
return 0;
var index = NormalizeCursor(cursor, count);
cursor = (index + 1) % count;
return index;
}
private static int NormalizeCursor(int cursor, int count)
{
if (count <= 0)
return 0;
if (cursor < 0)
return 0;
return cursor % count;
}
private static async Task<bool> DelayAsync(IModuleTaskExecutionHost host, int minDelayMs, int maxDelayMs, CancellationToken cancellationToken)
{
var delayMs = NextDelayMilliseconds(minDelayMs, maxDelayMs);
if (delayMs <= 0)
return true;
var remaining = delayMs;
while (remaining > 0)
{
cancellationToken.ThrowIfCancellationRequested();
if (!await host.IsStillRunningAsync(cancellationToken))
return false;
var chunk = Math.Min(remaining, 1000);
await Task.Delay(chunk, cancellationToken);
remaining -= chunk;
}
return true;
}
private static int NextDelayMilliseconds(int minDelayMs, int maxDelayMs)
{
minDelayMs = Math.Max(0, minDelayMs);
maxDelayMs = Math.Max(minDelayMs, maxDelayMs);
return maxDelayMs <= minDelayMs
? minDelayMs
: Random.Shared.Next(minDelayMs, maxDelayMs + 1);
}
private static void AddFailure(MessageReportTaskConfig config, Account account, string messageReference, string reason)
{
config.RecentFailures ??= new List<MessageReportTaskRuntimeFailure>();
config.RecentFailures.Add(new MessageReportTaskRuntimeFailure
{
TimestampUtc = DateTime.UtcNow,
AccountId = account.Id,
AccountDisplay = BuildAccountDisplayName(account),
MessageReference = messageReference,
Reason = reason
});
if (config.RecentFailures.Count > MaxFailureLines)
config.RecentFailures = config.RecentFailures.TakeLast(MaxFailureLines).ToList();
}
private static string BuildAccountDisplayName(Account account)
{
return $"#{account.Id} {(string.IsNullOrWhiteSpace(account.DisplayPhone) ? account.Phone : account.DisplayPhone)}";
}
private static bool LooksLikePeerInvalid(string? error)
{
var text = (error ?? string.Empty).ToUpperInvariant();
return text.Contains("PEER_ID_INVALID", StringComparison.Ordinal)
|| text.Contains("CHAT_ID_INVALID", StringComparison.Ordinal)
|| text.Contains("CHANNEL_INVALID", StringComparison.Ordinal)
|| text.Contains("USERNAME_INVALID", StringComparison.Ordinal)
|| text.Contains("USERNAME_NOT_OCCUPIED", StringComparison.Ordinal);
}
private static string NormalizeReason(string? error)
{
return string.IsNullOrWhiteSpace(error) ? "Telegram 请求失败" : error.Trim();
}
private static string SerializeIndented(MessageReportTaskConfig config)
{
return JsonSerializer.Serialize(config, new JsonSerializerOptions { WriteIndented = true });
}
}
@@ -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<int> CategoryIds { get; set; } = new();
[JsonPropertyName("category_names")]
public List<string> CategoryNames { get; set; } = new();
[JsonPropertyName("message_references")]
public List<string> MessageReferences { get; set; } = new();
[JsonPropertyName("delay_min_ms")]
public int DelayMinMs { get; set; } = 15000;
[JsonPropertyName("delay_max_ms")]
public int DelayMaxMs { get; set; } = 45000;
[JsonPropertyName("max_reports")]
public int MaxReports { get; set; }
[JsonPropertyName("report_type")]
public string ReportType { get; set; } = MessageReportTaskReportTypes.Spam;
[JsonPropertyName("option_keywords")]
public List<string> OptionKeywords { get; set; } = new();
[JsonPropertyName("comments")]
public List<string> Comments { get; set; } = new();
[JsonPropertyName("comment_mode")]
public string CommentMode { get; set; } = MessageReportTaskModes.Queue;
[JsonPropertyName("completed_attempts")]
public int CompletedAttempts { get; set; }
[JsonPropertyName("failed_attempts")]
public int FailedAttempts { get; set; }
[JsonPropertyName("account_cursor")]
public int AccountCursor { get; set; }
[JsonPropertyName("message_cursor")]
public int MessageCursor { get; set; }
[JsonPropertyName("comment_cursor")]
public int CommentCursor { get; set; }
[JsonPropertyName("canceled")]
public bool Canceled { get; set; }
[JsonPropertyName("error")]
public string? Error { get; set; }
[JsonPropertyName("recent_failures")]
public List<MessageReportTaskRuntimeFailure> RecentFailures { get; set; } = new();
}
public sealed class MessageReportTaskRuntimeFailure
{
[JsonPropertyName("timestamp_utc")]
public DateTime TimestampUtc { get; set; } = DateTime.UtcNow;
[JsonPropertyName("account_id")]
public int AccountId { get; set; }
[JsonPropertyName("account_display")]
public string AccountDisplay { get; set; } = string.Empty;
[JsonPropertyName("message_reference")]
public string MessageReference { get; set; } = string.Empty;
[JsonPropertyName("reason")]
public string Reason { get; set; } = string.Empty;
}
public sealed record ParsedMessageReference(string Raw, string Target, int MessageId, string CanonicalId);
public static class MessageReportTaskInputHelper
{
public static List<int> NormalizeCategoryIds(IEnumerable<int>? values, int fallback)
{
var ids = (values ?? Array.Empty<int>())
.Where(x => x > 0)
.Distinct()
.ToList();
if (ids.Count == 0 && fallback > 0)
ids.Add(fallback);
return ids;
}
public static List<string> NormalizeCategoryNames(IEnumerable<string>? values, string? fallback)
{
var names = NormalizeLines(values, distinct: true);
var fallbackName = (fallback ?? string.Empty).Trim();
if (names.Count == 0 && fallbackName.Length > 0)
names.Add(fallbackName);
return names;
}
public static List<string> NormalizeLines(IEnumerable<string>? values, bool distinct = false)
{
var lines = (values ?? Array.Empty<string>())
.Select(x => (x ?? string.Empty).Trim())
.Where(x => x.Length > 0)
.ToList();
return distinct
? lines.Distinct(StringComparer.OrdinalIgnoreCase).ToList()
: lines;
}
public static List<string> ParseMultilineText(string? text, bool distinct = false)
{
if (string.IsNullOrWhiteSpace(text))
return new List<string>();
return NormalizeLines(
text.Replace("\r", string.Empty).Split('\n', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries),
distinct);
}
public static bool TryParseMessageReference(string? raw, out ParsedMessageReference? parsed, out string? error)
{
parsed = null;
error = null;
var value = (raw ?? string.Empty).Trim();
if (value.Length == 0)
{
error = "消息引用不能为空";
return false;
}
value = NormalizeTelegramPath(value);
if (value.Length == 0)
{
error = "消息引用格式无效";
return false;
}
var parts = value.Split('/', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
string target;
string messageIdText;
if (parts.Length >= 3 && string.Equals(parts[0], "c", StringComparison.OrdinalIgnoreCase))
{
if (!long.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var internalChatId) || internalChatId <= 0)
{
error = "t.me/c 链接中的频道 ID 无效";
return false;
}
target = $"-100{internalChatId}";
messageIdText = parts[2];
}
else if (parts.Length >= 2)
{
target = parts[0];
messageIdText = parts[1];
}
else
{
error = "消息引用格式无效,应类似 username/123 或 t.me/c/1234567890/123";
return false;
}
target = target.Trim().TrimStart('@');
if (target.Length == 0)
{
error = "消息引用缺少目标";
return false;
}
if (!int.TryParse(messageIdText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var messageId) || messageId <= 0)
{
error = "消息 ID 无效,必须是正整数";
return false;
}
parsed = new ParsedMessageReference((raw ?? string.Empty).Trim(), target, messageId, $"{target}/{messageId}");
return true;
}
private static string NormalizeTelegramPath(string value)
{
var raw = value.Trim();
if (Uri.TryCreate(raw, UriKind.Absolute, out var absolute))
{
var host = (absolute.Host ?? string.Empty).Trim().ToLowerInvariant();
if (host is not ("t.me" or "www.t.me" or "telegram.me" or "www.telegram.me"))
return string.Empty;
raw = absolute.PathAndQuery;
}
else if (raw.StartsWith("t.me/", StringComparison.OrdinalIgnoreCase)
|| raw.StartsWith("telegram.me/", StringComparison.OrdinalIgnoreCase))
{
raw = raw[(raw.IndexOf('/') + 1)..];
}
var questionMarkIndex = raw.IndexOf('?');
if (questionMarkIndex >= 0)
raw = raw[..questionMarkIndex];
raw = raw.Trim().Trim('/').Replace('\\', '/');
if (raw.StartsWith("s/", StringComparison.OrdinalIgnoreCase))
raw = raw[2..];
return raw.Trim('/');
}
}
@@ -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<MessageReportTaskTelegramService>();
services.AddScoped<IModuleTaskHandler, MessageReportTaskHandler>();
services.AddSingleton<IModuleTaskRerunBuilder, MessageReportTaskRerunBuilder>();
}
public void MapEndpoints(IEndpointRouteBuilder endpoints, ModuleHostContext context)
{
}
public IEnumerable<ModuleTaskDefinition> GetTasks(ModuleHostContext context)
{
yield return new ModuleTaskDefinition
{
Category = "user",
TaskType = MessageReportTaskTypes.TaskType,
DisplayName = "消息举报(多账号)",
Description = "按账号分类持续举报指定消息,支持间隔控制、达到次数后停止,也支持 Cron 计划触发。",
Icon = Icons.Material.Filled.Warning,
EditorComponentType = typeof(MessageReportTaskEditor).AssemblyQualifiedName ?? string.Empty,
TaskCenter = new ModuleTaskCenterCapabilities
{
CanPause = true,
CanResume = true,
CanEdit = true,
CanRerun = true,
EditComponentType = typeof(MessageReportTaskEditor).AssemblyQualifiedName ?? string.Empty,
AutoPauseBeforeEdit = true
},
Order = 160
};
}
}
@@ -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<MessageReportTaskConfig>(raw)
?? throw new InvalidOperationException("任务配置解析结果为空");
}
catch (Exception ex)
{
throw new InvalidOperationException($"任务配置 JSON 无效:{ex.Message}");
}
cfg.CategoryIds = MessageReportTaskInputHelper.NormalizeCategoryIds(cfg.CategoryIds, cfg.CategoryId);
if (cfg.CategoryIds.Count == 0)
throw new InvalidOperationException("任务缺少账号分类,无法重新运行");
cfg.CategoryNames = MessageReportTaskInputHelper.NormalizeCategoryNames(cfg.CategoryNames, cfg.CategoryName);
cfg.CategoryId = cfg.CategoryIds[0];
cfg.CategoryName = cfg.CategoryNames.FirstOrDefault() ?? cfg.CategoryName;
cfg.MessageReferences = MessageReportTaskInputHelper.NormalizeLines(cfg.MessageReferences, distinct: true);
cfg.OptionKeywords = MessageReportTaskInputHelper.NormalizeLines(cfg.OptionKeywords, distinct: true);
cfg.Comments = MessageReportTaskInputHelper.NormalizeLines(cfg.Comments);
cfg.ReportType = MessageReportTaskReportTypes.Normalize(cfg.ReportType);
cfg.CommentMode = MessageReportTaskModes.Normalize(cfg.CommentMode);
foreach (var messageReference in cfg.MessageReferences)
{
if (!MessageReportTaskInputHelper.TryParseMessageReference(messageReference, out _, out var error))
throw new InvalidOperationException($"消息引用无效:{messageReference}({error})");
}
if (cfg.MessageReferences.Count == 0)
throw new InvalidOperationException("任务缺少消息引用,无法重新运行");
cfg.DelayMinMs = Math.Clamp(cfg.DelayMinMs, 0, 600000);
cfg.DelayMaxMs = Math.Clamp(cfg.DelayMaxMs, cfg.DelayMinMs, 600000);
cfg.MaxReports = Math.Max(0, cfg.MaxReports);
cfg.CompletedAttempts = 0;
cfg.FailedAttempts = 0;
cfg.AccountCursor = 0;
cfg.MessageCursor = 0;
cfg.CommentCursor = 0;
cfg.Canceled = false;
cfg.Error = null;
cfg.RecentFailures = new List<MessageReportTaskRuntimeFailure>();
return cfg;
}
}
@@ -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<string, string[]> ReportTypeKeywords = new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
{
[MessageReportTaskReportTypes.Spam] = ["spam", "垃圾", "广告"],
[MessageReportTaskReportTypes.Violence] = ["violence", "violent", "暴力"],
[MessageReportTaskReportTypes.Pornography] = ["porn", "pornography", "sexual", "nsfw", "色情", "成人"],
[MessageReportTaskReportTypes.ChildAbuse] = ["child", "minor", "underage", "儿童", "未成年", "虐待", "剥削"],
[MessageReportTaskReportTypes.Copyright] = ["copyright", "版权", "侵权", "盗版"],
[MessageReportTaskReportTypes.PersonalDetails] = ["personal", "private", "privacy", "隐私", "个人信息", "泄露"],
[MessageReportTaskReportTypes.IllegalDrugs] = ["drug", "drugs", "毒品", "违禁药"],
[MessageReportTaskReportTypes.Fake] = ["fake", "fraud", "scam", "冒充", "诈骗", "虚假"],
[MessageReportTaskReportTypes.Other] = ["other", "其他"]
};
private readonly AccountManagementService _accountManagement;
private readonly ITelegramClientPool _clientPool;
private readonly AccountTelegramToolsService _accountTools;
private readonly IConfiguration _configuration;
private readonly ILogger<MessageReportTaskTelegramService> _logger;
public MessageReportTaskTelegramService(
AccountManagementService accountManagement,
ITelegramClientPool clientPool,
AccountTelegramToolsService accountTools,
IConfiguration configuration,
ILogger<MessageReportTaskTelegramService> logger)
{
_accountManagement = accountManagement;
_clientPool = clientPool;
_accountTools = accountTools;
_configuration = configuration;
_logger = logger;
}
public async Task<(bool Success, string? Error, ResolvedMessageTarget? Target)> ResolveMessageReferenceAsync(
int accountId,
string rawReference,
CancellationToken cancellationToken)
{
if (!MessageReportTaskInputHelper.TryParseMessageReference(rawReference, out var parsed, out var parseError) || parsed == null)
return (false, parseError ?? "消息引用无效", null);
var resolved = await _accountTools.ResolveChatTargetAsync(accountId, parsed.Target, cancellationToken);
if (!resolved.Success || resolved.Target == null)
return (false, NormalizeError(resolved.Error), null);
return (true, null, new ResolvedMessageTarget(parsed, resolved.Target));
}
public async Task<ReportMessageResult> ReportMessageAsync(
int accountId,
ResolvedMessageTarget target,
MessageReportTaskConfig config,
int commentCursor,
CancellationToken cancellationToken)
{
try
{
var client = await GetOrCreateConnectedClientAsync(accountId, cancellationToken);
var option = Array.Empty<byte>();
var comment = string.Empty;
string? selectedOptionText = null;
var nextCommentCursor = commentCursor;
for (var step = 0; step < 6; step++)
{
cancellationToken.ThrowIfCancellationRequested();
var result = await ExecuteTelegramRequestAsync(
accountId,
"举报消息",
() => client.Messages_Report(target.ChatTarget.Peer, [target.Reference.MessageId], option, comment),
cancellationToken,
resetClientOnTimeout: true);
switch (result)
{
case ReportResultReported:
return ReportMessageResult.Ok(nextCommentCursor, selectedOptionText);
case ReportResultChooseOption chooseOption:
{
var selected = SelectOption(chooseOption.options, config.ReportType, config.OptionKeywords, out var matchError);
if (selected == null)
return ReportMessageResult.Fail(matchError ?? "无法匹配 Telegram 返回的举报菜单", nextCommentCursor);
option = selected.option ?? Array.Empty<byte>();
comment = string.Empty;
selectedOptionText = selected.text;
break;
}
case ReportResultAddComment addComment:
{
option = addComment.option ?? option;
var selectedComment = PickComment(config.Comments, config.CommentMode, nextCommentCursor, out nextCommentCursor);
if (string.IsNullOrWhiteSpace(selectedComment))
{
if (!addComment.flags.HasFlag(ReportResultAddComment.Flags.optional))
return ReportMessageResult.Fail("当前举报步骤要求填写举报说明,但任务配置未提供可用文案", nextCommentCursor);
comment = string.Empty;
}
else
{
comment = selectedComment;
}
break;
}
default:
return ReportMessageResult.Fail($"收到未知举报结果类型:{result.GetType().Name}", nextCommentCursor);
}
}
return ReportMessageResult.Fail("举报流程超过允许的最大步骤数", commentCursor);
}
catch (Exception ex)
{
var mapped = AccountTelegramToolsService.MapTelegramException(ex);
_logger.LogWarning(ex, "Message report failed for account {AccountId}, target {Target}", accountId, target.Reference.CanonicalId);
return ReportMessageResult.Fail(string.IsNullOrWhiteSpace(mapped.details) ? mapped.summary : $"{mapped.summary}:{mapped.details}", commentCursor);
}
}
private async Task<Client> GetOrCreateConnectedClientAsync(int accountId, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var existing = _clientPool.GetClient(accountId);
if (existing?.User != null)
return existing;
var account = await _accountManagement.GetAccountAsync(accountId)
?? throw new InvalidOperationException($"账号不存在:{accountId}");
var apiId = ResolveApiId(account);
var apiHash = ResolveApiHash(account);
var sessionKey = ResolveSessionKey(account, apiHash);
if (string.IsNullOrWhiteSpace(account.SessionPath))
throw new InvalidOperationException("账号缺少 SessionPath,无法创建 Telegram 客户端");
var absoluteSessionPath = Path.GetFullPath(account.SessionPath);
if (File.Exists(absoluteSessionPath) && SessionDataConverter.LooksLikeSqliteSession(absoluteSessionPath))
{
var converted = await SessionDataConverter.TryConvertSqliteSessionFromJsonAsync(
phone: account.Phone,
apiId: account.ApiId,
apiHash: account.ApiHash,
sqliteSessionPath: absoluteSessionPath,
logger: _logger);
if (!converted.Ok)
{
throw new InvalidOperationException(
$"该账号的 Session 文件为 SQLite 格式:{account.SessionPath},无法自动转换为可用 session。原因:{converted.Reason}。"
+ "建议:重新导入包含 session_string 的 json,或到【账号-手机号登录】重新登录生成新的 sessions/*.session。"
);
}
}
await _clientPool.RemoveClientAsync(accountId);
var client = await _clientPool.GetOrCreateClientAsync(
accountId: accountId,
apiId: apiId,
apiHash: apiHash,
sessionPath: account.SessionPath,
sessionKey: sessionKey,
phoneNumber: account.Phone,
userId: account.UserId > 0 ? account.UserId : null);
try
{
await ExecuteTelegramRequestAsync(
accountId,
"连接 Telegram",
() => client.ConnectAsync(),
cancellationToken,
resetClientOnTimeout: true);
if (client.User == null && (client.UserId != 0 || account.UserId != 0))
{
await ExecuteTelegramRequestAsync(
accountId,
"恢复 Telegram 登录状态",
() => client.LoginUserIfNeeded(reloginOnFailedResume: false),
cancellationToken,
resetClientOnTimeout: true);
}
}
catch (Exception ex)
{
if (LooksLikeSessionApiMismatchOrCorrupted(ex))
{
throw new InvalidOperationException(
"该账号的 Session 文件无法解析(通常是 ApiId/ApiHash 与生成 session 时不一致,或 session 文件已损坏)。"
+ "请到【账号-手机号登录】重新登录生成新的 session 后再试。",
ex);
}
throw new InvalidOperationException($"Telegram 会话加载失败:{ex.Message}", ex);
}
if (client.User == null)
throw new InvalidOperationException("账号未登录或 session 已失效,请重新登录生成新的 session");
return client;
}
private async Task ExecuteTelegramRequestAsync(
int accountId,
string operation,
Func<Task> action,
CancellationToken cancellationToken,
bool resetClientOnTimeout = false)
{
try
{
await action().WaitAsync(GetTelegramRequestTimeout(), cancellationToken);
}
catch (TimeoutException)
{
if (resetClientOnTimeout)
await _clientPool.RemoveClientAsync(accountId);
throw new TimeoutException($"{operation} 超时");
}
}
private async Task<T> ExecuteTelegramRequestAsync<T>(
int accountId,
string operation,
Func<Task<T>> action,
CancellationToken cancellationToken,
bool resetClientOnTimeout = false)
{
try
{
return await action().WaitAsync(GetTelegramRequestTimeout(), cancellationToken);
}
catch (TimeoutException)
{
if (resetClientOnTimeout)
await _clientPool.RemoveClientAsync(accountId);
throw new TimeoutException($"{operation} 超时");
}
}
private TimeSpan GetTelegramRequestTimeout()
{
if (int.TryParse(_configuration["Telegram:RequestTimeoutSeconds"], out var seconds) && seconds >= 10 && seconds <= 600)
return TimeSpan.FromSeconds(seconds);
return TimeSpan.FromSeconds(45);
}
private static MessageReportOption? SelectOption(
IEnumerable<MessageReportOption>? options,
string reportType,
IEnumerable<string>? customKeywords,
out string? error)
{
error = null;
var optionList = (options ?? Array.Empty<MessageReportOption>()).ToList();
if (optionList.Count == 0)
{
error = "Telegram 未返回可用的举报选项";
return null;
}
var keywords = BuildKeywords(reportType, customKeywords).ToList();
if (keywords.Count > 0)
{
foreach (var candidate in optionList)
{
var normalizedText = NormalizeForMatch(candidate.text);
if (keywords.Any(keyword => normalizedText.Contains(keyword, StringComparison.Ordinal)))
return candidate;
}
}
if (string.Equals(reportType, MessageReportTaskReportTypes.Auto, StringComparison.OrdinalIgnoreCase))
return optionList[0];
if (optionList.Count == 1)
return optionList[0];
error = "未能根据当前配置匹配 Telegram 返回的举报选项:"
+ string.Join(" / ", optionList.Select(x => string.IsNullOrWhiteSpace(x.text) ? "<空标题>" : x.text.Trim()));
return null;
}
private static IEnumerable<string> BuildKeywords(string reportType, IEnumerable<string>? customKeywords)
{
var normalizedReportType = MessageReportTaskReportTypes.Normalize(reportType);
if (ReportTypeKeywords.TryGetValue(normalizedReportType, out var defaultKeywords))
{
foreach (var keyword in defaultKeywords)
{
var normalized = NormalizeForMatch(keyword);
if (normalized.Length > 0)
yield return normalized;
}
}
foreach (var keyword in MessageReportTaskInputHelper.NormalizeLines(customKeywords, distinct: true))
{
var normalized = NormalizeForMatch(keyword);
if (normalized.Length > 0)
yield return normalized;
}
}
private static string NormalizeForMatch(string? value)
{
return new string((value ?? string.Empty)
.Trim()
.ToLowerInvariant()
.Where(ch => !char.IsWhiteSpace(ch) && ch is not '-' and not '_' and not ':' and not ':')
.ToArray());
}
private static string? PickComment(IReadOnlyList<string>? comments, string mode, int cursor, out int nextCursor)
{
var normalized = MessageReportTaskInputHelper.NormalizeLines(comments, distinct: false);
if (normalized.Count == 0)
{
nextCursor = cursor;
return null;
}
if (string.Equals(MessageReportTaskModes.Normalize(mode), MessageReportTaskModes.Queue, StringComparison.OrdinalIgnoreCase))
{
var index = NormalizeCursor(cursor, normalized.Count);
nextCursor = normalized.Count == 0 ? 0 : (index + 1) % normalized.Count;
return normalized[index];
}
nextCursor = cursor;
return normalized[Random.Shared.Next(normalized.Count)];
}
private static int NormalizeCursor(int cursor, int count)
{
if (count <= 0)
return 0;
if (cursor < 0)
return 0;
return cursor % count;
}
private static string NormalizeError(string? error)
{
return string.IsNullOrWhiteSpace(error) ? "Telegram 请求失败" : error.Trim();
}
private int ResolveApiId(Account account)
{
if (int.TryParse(_configuration["Telegram:ApiId"], out var globalApiId) && globalApiId > 0)
return globalApiId;
if (account.ApiId > 0)
return account.ApiId;
throw new InvalidOperationException("未配置全局 ApiId,且账号缺少 ApiId");
}
private string ResolveApiHash(Account account)
{
var global = _configuration["Telegram:ApiHash"];
if (!string.IsNullOrWhiteSpace(global))
return global.Trim();
if (!string.IsNullOrWhiteSpace(account.ApiHash))
return account.ApiHash.Trim();
throw new InvalidOperationException("未配置全局 ApiHash,且账号缺少 ApiHash");
}
private static string ResolveSessionKey(Account account, string apiHash)
{
return !string.IsNullOrWhiteSpace(account.ApiHash) ? account.ApiHash.Trim() : apiHash.Trim();
}
private static bool LooksLikeSessionApiMismatchOrCorrupted(Exception ex)
{
var message = ex.Message ?? string.Empty;
return message.Contains("Can't read session block", StringComparison.OrdinalIgnoreCase)
|| message.Contains("Use the correct api_hash", StringComparison.OrdinalIgnoreCase)
|| message.Contains("Use the correct api_hash/id/key", StringComparison.OrdinalIgnoreCase);
}
}
public sealed record ResolvedMessageTarget(
ParsedMessageReference Reference,
AccountTelegramToolsService.ResolvedChatTarget ChatTarget);
public sealed record ReportMessageResult(bool Success, string? Error, int NextCommentCursor, string? SelectedOptionText)
{
public static ReportMessageResult Ok(int nextCommentCursor, string? selectedOptionText)
=> new(true, null, nextCommentCursor, selectedOptionText);
public static ReportMessageResult Fail(string error, int nextCommentCursor)
=> new(false, error, nextCommentCursor, null);
}
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk.Razor">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AssemblyName>TelegramPanel.Modules.MessageReport</AssemblyName>
<RootNamespace>TelegramPanel.Modules.MessageReport</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\TelegramPanel.Modules.Abstractions\TelegramPanel.Modules.Abstractions.csproj" />
<ProjectReference Include="..\TelegramPanel.Core\TelegramPanel.Core.csproj" />
<ProjectReference Include="..\TelegramPanel.Data\TelegramPanel.Data.csproj" />
<PackageReference Include="MudBlazor" Version="7.*" />
</ItemGroup>
<ItemGroup>
<Content Remove="publish/**" />
<None Remove="publish/**" />
<Content Update="manifest.json" CopyToOutputDirectory="Never" CopyToPublishDirectory="Never" />
<None Update="manifest.json" CopyToOutputDirectory="Never" CopyToPublishDirectory="Never" />
</ItemGroup>
</Project>
@@ -0,0 +1,4 @@
@using Microsoft.AspNetCore.Components
@using MudBlazor
@using TelegramPanel.Modules
@using TelegramPanel.Modules.MessageReport
@@ -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"
}
}
@@ -0,0 +1,315 @@
@using System.Text.Json
@using TelegramPanel.Modules.MessageReportTask.Services
@inject AccountCategoryManagementService CategoryManagement
<MudStack Spacing="2">
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Dense="true">
该任务会按账号分类轮流对指定消息链接执行举报。最多举报次数为 0 表示不设上限,直到所有可用账号-消息组合处理完成或你在任务中心手动停止。
</MudAlert>
<MudAlert Severity="Severity.Warning" Variant="Variant.Outlined" Dense="true">
Telegram 的消息举报是动态选项流程。这里的“举报类型”本质上是匹配 Telegram 返回的选项文字;如果匹配不到,任务会记失败,但不会盲选错误类型。
</MudAlert>
@if (loading)
{
<MudProgressCircular Indeterminate="true" Color="Color.Primary" Size="Size.Small" />
<MudText Typo="Typo.caption">正在加载账号分类...</MudText>
}
else
{
<MudSelect T="int"
Label="执行账号分类(可多选)"
Variant="Variant.Outlined"
Dense="true"
MultiSelection="true"
SelectedValues="selectedCategoryIds"
SelectedValuesChanged="OnSelectedCategoriesChanged"
HelperText="任务会从所有选中分类中抽取可用账号依次执行举报。">
@foreach (var c in categories)
{
<MudSelectItem Value="@c.Id">@c.Name</MudSelectItem>
}
</MudSelect>
<MudTextField @bind-Value="messageLinksText" @bind-Value:after="OnInputsChanged"
Label="消息链接(每行一个)"
Variant="Variant.Outlined"
Lines="6"
Immediate="true"
HelperText="支持:https://t.me/username/123、https://t.me/c/123456/789、tg://privatepost?channel=123456&post=789、username/123、-1001234567890/123" />
<MudGrid>
<MudItem xs="12" md="4">
<MudNumericField T="int" @bind-Value="delayMinSeconds" @bind-Value:after="OnInputsChanged"
Label="最小间隔(秒)" Variant="Variant.Outlined" Min="0" Max="86400"
HelperText="与最大间隔相同即固定间隔。" />
</MudItem>
<MudItem xs="12" md="4">
<MudNumericField T="int" @bind-Value="delayMaxSeconds" @bind-Value:after="OnInputsChanged"
Label="最大间隔(秒)" Variant="Variant.Outlined" Min="0" Max="86400" />
</MudItem>
<MudItem xs="12" md="4">
<MudNumericField T="int" @bind-Value="maxReports" @bind-Value:after="OnInputsChanged"
Label="最多举报次数" Variant="Variant.Outlined" Min="0" Max="1000000"
HelperText="0 表示不限制次数上限。" />
</MudItem>
</MudGrid>
<MudSelect T="string" @bind-Value="reportPreset" @bind-Value:after="OnInputsChanged"
Label="举报类型预设" Variant="Variant.Outlined" Dense="true"
HelperText="预设会转成关键词去匹配 Telegram 返回的举报选项。">
<MudSelectItem Value="@MessageReportTaskPresets.Spam">垃圾 / 骚扰</MudSelectItem>
<MudSelectItem Value="@MessageReportTaskPresets.Violence">暴力 / 威胁</MudSelectItem>
<MudSelectItem Value="@MessageReportTaskPresets.Pornography">色情 / 淫秽</MudSelectItem>
<MudSelectItem Value="@MessageReportTaskPresets.ChildAbuse">儿童虐待</MudSelectItem>
<MudSelectItem Value="@MessageReportTaskPresets.Copyright">版权侵权</MudSelectItem>
<MudSelectItem Value="@MessageReportTaskPresets.IllegalDrugs">违禁药物</MudSelectItem>
<MudSelectItem Value="@MessageReportTaskPresets.PersonalDetails">隐私 / 个人信息</MudSelectItem>
<MudSelectItem Value="@MessageReportTaskPresets.Other">其他</MudSelectItem>
<MudSelectItem Value="@MessageReportTaskPresets.FirstAvailable">直接选第一个选项</MudSelectItem>
<MudSelectItem Value="@MessageReportTaskPresets.Custom">自定义关键词</MudSelectItem>
</MudSelect>
<MudTextField @bind-Value="customKeywordsText" @bind-Value:after="OnInputsChanged"
Label="自定义选项关键词(可选,每行一个)"
Variant="Variant.Outlined"
Lines="4"
Immediate="true"
HelperText="优先于预设关键词。适合 Telegram 账号语言与预设不一致时手动微调。" />
<MudTextField @bind-Value="commentText" @bind-Value:after="OnInputsChanged"
Label="举报文案(可选)"
Variant="Variant.Outlined"
Lines="4"
Immediate="true"
HelperText="当 Telegram 要求补充说明时会自动提交。支持变量:{消息链接}、{聊天标题}、{消息ID}" />
}
</MudStack>
@code {
[Parameter] public ModuleTaskDraft Draft { get; set; }
[Parameter] public EventCallback<ModuleTaskDraft> DraftChanged { get; set; }
[Parameter] public string? InitialConfigJson { get; set; }
private List<AccountCategory> categories = new();
private bool loading = true;
private bool initializedFromConfig;
private IEnumerable<int> selectedCategoryIds = Array.Empty<int>();
private string messageLinksText = string.Empty;
private int delayMinSeconds = 15;
private int delayMaxSeconds = 45;
private int maxReports;
private string reportPreset = MessageReportTaskPresets.Spam;
private string customKeywordsText = string.Empty;
private string commentText = string.Empty;
protected override async Task OnInitializedAsync()
{
ApplyInitialConfigIfNeeded();
try
{
categories = (await CategoryManagement.GetAllCategoriesAsync())
.Where(x => !x.ExcludeFromOperations)
.OrderBy(x => x.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
if (!selectedCategoryIds.Any())
{
var firstCategoryId = categories.FirstOrDefault()?.Id ?? 0;
selectedCategoryIds = firstCategoryId > 0 ? new[] { firstCategoryId } : Array.Empty<int>();
}
}
finally
{
loading = false;
await PushDraftAsync();
}
}
private void ApplyInitialConfigIfNeeded()
{
if (initializedFromConfig)
return;
initializedFromConfig = true;
var raw = (InitialConfigJson ?? string.Empty).Trim();
if (raw.Length == 0)
return;
try
{
var cfg = JsonSerializer.Deserialize<MessageReportTaskConfig>(raw);
if (cfg == null)
return;
var categoryIds = NormalizeCategoryIds(cfg.CategoryIds, cfg.CategoryId);
if (categoryIds.Count > 0)
selectedCategoryIds = categoryIds;
messageLinksText = string.Join(Environment.NewLine, cfg.MessageLinks ?? new List<string>());
delayMinSeconds = ConvertMillisecondsToSeconds(cfg.DelayMinMs);
delayMaxSeconds = ConvertMillisecondsToSeconds(cfg.DelayMaxMs);
maxReports = Math.Max(0, cfg.MaxReports);
reportPreset = NormalizePreset(cfg.ReportPreset);
customKeywordsText = string.Join(Environment.NewLine, cfg.OptionKeywords ?? new List<string>());
commentText = (cfg.Comment ?? string.Empty).Trim();
}
catch
{
}
}
private async Task OnSelectedCategoriesChanged(IEnumerable<int> values)
{
selectedCategoryIds = NormalizeCategoryIds(values, 0);
await OnInputsChanged();
}
private async Task OnInputsChanged()
{
await PushDraftAsync();
}
private async Task PushDraftAsync()
{
var links = ParseLines(messageLinksText, distinct: true);
var selectedIds = NormalizeCategoryIds(selectedCategoryIds, 0);
var selectedCategories = categories.Where(x => selectedIds.Contains(x.Id)).ToList();
var keywords = ParseLines(customKeywordsText, distinct: true);
var normalizedPreset = NormalizePreset(reportPreset);
var comment = NormalizeNullableText(commentText);
if (loading)
{
await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "账号分类加载中"));
return;
}
if (categories.Count == 0)
{
await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "没有可用账号分类(或都被标记为排除操作)"));
return;
}
if (selectedCategories.Count == 0)
{
await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "请至少选择一个执行账号分类"));
return;
}
if (links.Count == 0)
{
await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "请至少填写一条消息链接"));
return;
}
if (delayMinSeconds < 0 || delayMaxSeconds < 0)
{
await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "间隔不能为负数"));
return;
}
if (delayMaxSeconds < delayMinSeconds)
{
await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "最大间隔不能小于最小间隔"));
return;
}
if (maxReports < 0)
{
await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "最多举报次数不能小于 0"));
return;
}
if (normalizedPreset == MessageReportTaskPresets.Custom && keywords.Count == 0)
{
await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "选择“自定义关键词”时,至少填写一个关键词"));
return;
}
var categoryNames = selectedCategories.Select(x => x.Name).ToList();
var cfg = new MessageReportTaskConfig
{
CategoryId = selectedCategories[0].Id,
CategoryName = selectedCategories[0].Name,
CategoryIds = selectedCategories.Select(x => x.Id).ToList(),
CategoryNames = categoryNames,
MessageLinks = links,
DelayMinMs = delayMinSeconds * 1000,
DelayMaxMs = delayMaxSeconds * 1000,
MaxReports = maxReports,
ReportPreset = normalizedPreset,
OptionKeywords = keywords,
Comment = comment
};
var total = maxReports > 0 ? maxReports : 0;
var json = JsonSerializer.Serialize(cfg, new JsonSerializerOptions { WriteIndented = true });
await DraftChanged.InvokeAsync(new ModuleTaskDraft(total, json, true, null));
}
private static List<int> NormalizeCategoryIds(IEnumerable<int>? values, int fallback)
{
var ids = (values ?? Array.Empty<int>())
.Where(x => x > 0)
.Distinct()
.ToList();
if (ids.Count == 0 && fallback > 0)
ids.Add(fallback);
return ids;
}
private static int ConvertMillisecondsToSeconds(int value)
{
if (value <= 0)
return 0;
return Math.Max(1, (int)Math.Ceiling(value / 1000d));
}
private static string NormalizePreset(string? value)
{
var preset = (value ?? string.Empty).Trim().ToLowerInvariant();
return preset switch
{
MessageReportTaskPresets.Spam => MessageReportTaskPresets.Spam,
MessageReportTaskPresets.Violence => MessageReportTaskPresets.Violence,
MessageReportTaskPresets.Pornography => MessageReportTaskPresets.Pornography,
MessageReportTaskPresets.ChildAbuse => MessageReportTaskPresets.ChildAbuse,
MessageReportTaskPresets.Copyright => MessageReportTaskPresets.Copyright,
MessageReportTaskPresets.IllegalDrugs => MessageReportTaskPresets.IllegalDrugs,
MessageReportTaskPresets.PersonalDetails => MessageReportTaskPresets.PersonalDetails,
MessageReportTaskPresets.Other => MessageReportTaskPresets.Other,
MessageReportTaskPresets.FirstAvailable => MessageReportTaskPresets.FirstAvailable,
MessageReportTaskPresets.Custom => MessageReportTaskPresets.Custom,
_ => MessageReportTaskPresets.Spam
};
}
private static List<string> ParseLines(string? text, bool distinct)
{
if (string.IsNullOrWhiteSpace(text))
return new List<string>();
var values = text
.Split(new[] { "\r\n", "\n", "\r" }, StringSplitOptions.RemoveEmptyEntries)
.Select(x => (x ?? string.Empty).Trim())
.Where(x => x.Length > 0);
return distinct
? values.Distinct(StringComparer.OrdinalIgnoreCase).ToList()
: values.ToList();
}
private static string? NormalizeNullableText(string? value)
{
var text = (value ?? string.Empty).Trim();
return text.Length == 0 ? null : text;
}
}
@@ -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<TelegramMessageReportService>();
services.AddScoped<IModuleTaskHandler, MessageReportTaskHandler>();
services.AddSingleton<IModuleTaskRerunBuilder, MessageReportTaskRerunBuilder>();
}
public void MapEndpoints(IEndpointRouteBuilder endpoints, ModuleHostContext context)
{
}
public IEnumerable<ModuleTaskDefinition> GetTasks(ModuleHostContext context)
{
yield return new ModuleTaskDefinition
{
Category = "user",
TaskType = MessageReportTaskConstants.TaskType,
DisplayName = "消息举报(多账号)",
Description = "按账号分类对指定消息链接执行举报,支持间隔、Cron 计划和数量上限。",
Icon = Icons.Material.Filled.Flag,
EditorComponentType = typeof(MessageReportTaskEditor).AssemblyQualifiedName ?? string.Empty,
TaskCenter = new ModuleTaskCenterCapabilities
{
CanPause = true,
CanResume = true,
CanEdit = true,
CanRerun = true,
EditComponentType = typeof(MessageReportTaskEditor).AssemblyQualifiedName ?? string.Empty,
AutoPauseBeforeEdit = true
},
Order = 150
};
}
}
@@ -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<ILogger<MessageReportTaskHandler>>();
var taskManagement = host.Services.GetRequiredService<BatchTaskManagementService>();
var accountManagement = host.Services.GetRequiredService<AccountManagementService>();
var reportService = host.Services.GetRequiredService<TelegramMessageReportService>();
var config = DeserializeConfig(host.Config);
ValidateAndNormalizeConfig(config);
var selectedCategoryIds = NormalizeSelectedCategoryIds(config).ToHashSet();
var allAccounts = (await accountManagement.GetAllAccountsAsync())
.Where(x => x.IsActive && x.UserId > 0 && x.Category?.ExcludeFromOperations != true)
.Where(x => x.CategoryId.HasValue && selectedCategoryIds.Contains(x.CategoryId.Value))
.OrderBy(x => x.Id)
.ToList();
if (allAccounts.Count() == 0)
throw new InvalidOperationException("所选分类下没有可用执行账号");
var accountSlots = new List<AccountSlot>();
foreach (var account in allAccounts)
{
cancellationToken.ThrowIfCancellationRequested();
if (!await host.IsStillRunningAsync(cancellationToken))
{
config.Canceled = true;
await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(config));
return;
}
var slot = new AccountSlot(account);
foreach (var rawLink in config.MessageLinks)
{
cancellationToken.ThrowIfCancellationRequested();
if (!await host.IsStillRunningAsync(cancellationToken))
{
config.Canceled = true;
await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(config));
return;
}
var resolved = await reportService.ResolveMessageTargetAsync(account.Id, rawLink, cancellationToken);
if (resolved.Success && resolved.Target != null)
{
slot.Targets.Add(new TargetSlot(resolved.Target));
continue;
}
AddFailure(config, account, rawLink, NormalizeReason(resolved.Error));
}
if (slot.Targets.Count > 0)
accountSlots.Add(slot);
}
if (accountSlots.Count == 0)
{
config.Error = "没有可用的账号-消息组合(请确认这些账号能访问目标消息)";
await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(config));
throw new InvalidOperationException(config.Error);
}
config.Error = null;
await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(config));
var completed = 0;
var failed = 0;
var accountQueueIndex = 0;
var targetQueueIndexByAccountId = new Dictionary<int, int>();
var lastProgressPersistAt = DateTime.UtcNow;
try
{
while (!cancellationToken.IsCancellationRequested && accountSlots.Count > 0)
{
cancellationToken.ThrowIfCancellationRequested();
if (!await host.IsStillRunningAsync(cancellationToken))
{
config.Canceled = true;
break;
}
if (config.MaxReports > 0 && completed >= config.MaxReports)
break;
var accountIdx = SelectNextAccountIndex(accountSlots, ref accountQueueIndex);
if (accountIdx < 0)
break;
var accountSlot = accountSlots[accountIdx];
var targetIdx = SelectNextTargetIndex(accountSlot, targetQueueIndexByAccountId);
if (targetIdx < 0)
{
accountSlots.RemoveAt(accountIdx);
targetQueueIndexByAccountId.Remove(accountSlot.Account.Id);
continue;
}
var targetSlot = accountSlot.Targets[targetIdx];
var result = await reportService.ReportMessageAsync(accountSlot.Account.Id, targetSlot.Target, config, cancellationToken);
completed++;
var hadFailureThisRound = false;
var shouldRemoveTarget = result.Success || result.ShouldRemoveTarget;
if (!result.Success)
{
failed++;
hadFailureThisRound = true;
targetSlot.ConsecutiveFailures++;
AddFailure(config, accountSlot.Account, targetSlot.Target.RawInput, NormalizeReason(result.Error));
if (targetSlot.ConsecutiveFailures >= 3)
shouldRemoveTarget = true;
await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(config));
}
if (shouldRemoveTarget)
{
accountSlot.Targets.RemoveAt(targetIdx);
if (accountSlot.Targets.Count == 0)
{
accountSlots.RemoveAt(accountIdx);
targetQueueIndexByAccountId.Remove(accountSlot.Account.Id);
}
}
if (ShouldPersistProgress(completed, hadFailureThisRound, lastProgressPersistAt))
{
await host.UpdateProgressAsync(completed, failed, cancellationToken);
lastProgressPersistAt = DateTime.UtcNow;
}
if (config.MaxReports > 0 && completed >= config.MaxReports)
break;
if (accountSlots.Count == 0)
break;
var delayMs = NextDelayMilliseconds(config.DelayMinMs, config.DelayMaxMs);
if (delayMs > 0)
{
var keepRunning = await DelayWithTaskCheckAsync(host, delayMs, cancellationToken);
if (!keepRunning)
{
config.Canceled = true;
break;
}
}
}
}
catch (Exception ex)
{
logger.LogWarning(ex, "MessageReport task failed (taskId={TaskId})", host.TaskId);
config.Error = ex.Message;
await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(config));
throw;
}
if (config.MaxReports > 0 && accountSlots.Count == 0 && completed < host.Total)
await taskManagement.UpdateTaskDraftAsync(host.TaskId, completed, SerializeIndented(config));
else
await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(config));
await host.UpdateProgressAsync(completed, failed, cancellationToken);
if (config.Canceled)
{
await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(config));
return;
}
config.Error = null;
await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(config));
}
private static MessageReportTaskConfig DeserializeConfig(string? rawConfig)
{
var raw = (rawConfig ?? string.Empty).Trim();
if (raw.Length == 0)
throw new InvalidOperationException("任务配置为空");
try
{
return JsonSerializer.Deserialize<MessageReportTaskConfig>(raw)
?? throw new InvalidOperationException("任务配置解析结果为空");
}
catch (Exception ex)
{
throw new InvalidOperationException($"任务配置 JSON 无效:{ex.Message}");
}
}
private static void ValidateAndNormalizeConfig(MessageReportTaskConfig config)
{
config.CategoryIds = NormalizeSelectedCategoryIds(config);
if (config.CategoryIds.Count == 0)
throw new InvalidOperationException("请至少选择一个执行账号分类");
config.CategoryId = config.CategoryIds[0];
config.CategoryNames = NormalizeCategoryNames(config.CategoryNames, config.CategoryName);
config.CategoryName = config.CategoryNames.FirstOrDefault() ?? config.CategoryName;
config.MessageLinks = (config.MessageLinks ?? new List<string>())
.Select(x => (x ?? string.Empty).Trim())
.Where(x => x.Length > 0)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (config.MessageLinks.Count == 0)
throw new InvalidOperationException("请至少填写一条消息链接");
config.OptionKeywords = (config.OptionKeywords ?? new List<string>())
.Select(x => (x ?? string.Empty).Trim())
.Where(x => x.Length > 0)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
config.ReportPreset = NormalizePreset(config.ReportPreset);
if (config.ReportPreset == MessageReportTaskPresets.Custom && config.OptionKeywords.Count == 0)
throw new InvalidOperationException("选择“自定义关键词”时,至少填写一个关键词");
config.Comment = NormalizeNullableText(config.Comment);
if (config.DelayMinMs < 0) config.DelayMinMs = 0;
if (config.DelayMaxMs < 0) config.DelayMaxMs = 0;
if (config.DelayMinMs > 86400000) config.DelayMinMs = 86400000;
if (config.DelayMaxMs > 86400000) config.DelayMaxMs = 86400000;
if (config.DelayMaxMs < config.DelayMinMs) config.DelayMaxMs = config.DelayMinMs;
if (config.MaxReports < 0) config.MaxReports = 0;
config.Canceled = false;
config.Error = NormalizeNullableText(config.Error);
config.RecentFailures ??= new List<MessageReportTaskRuntimeFailure>();
}
private static List<int> NormalizeSelectedCategoryIds(MessageReportTaskConfig config)
{
var ids = (config.CategoryIds ?? new List<int>())
.Where(x => x > 0)
.Distinct()
.ToList();
if (ids.Count == 0 && config.CategoryId > 0)
ids.Add(config.CategoryId);
return ids;
}
private static List<string> NormalizeCategoryNames(IEnumerable<string>? values, string? fallback)
{
var names = (values ?? Array.Empty<string>())
.Select(x => (x ?? string.Empty).Trim())
.Where(x => x.Length > 0)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
var fallbackName = (fallback ?? string.Empty).Trim();
if (names.Count == 0 && fallbackName.Length > 0)
names.Add(fallbackName);
return names;
}
private static string NormalizePreset(string? preset)
{
var value = (preset ?? string.Empty).Trim().ToLowerInvariant();
return value switch
{
MessageReportTaskPresets.Spam => MessageReportTaskPresets.Spam,
MessageReportTaskPresets.Violence => MessageReportTaskPresets.Violence,
MessageReportTaskPresets.Pornography => MessageReportTaskPresets.Pornography,
MessageReportTaskPresets.ChildAbuse => MessageReportTaskPresets.ChildAbuse,
MessageReportTaskPresets.Copyright => MessageReportTaskPresets.Copyright,
MessageReportTaskPresets.IllegalDrugs => MessageReportTaskPresets.IllegalDrugs,
MessageReportTaskPresets.PersonalDetails => MessageReportTaskPresets.PersonalDetails,
MessageReportTaskPresets.Other => MessageReportTaskPresets.Other,
MessageReportTaskPresets.FirstAvailable => MessageReportTaskPresets.FirstAvailable,
MessageReportTaskPresets.Custom => MessageReportTaskPresets.Custom,
_ => MessageReportTaskPresets.Spam
};
}
private static string? NormalizeNullableText(string? value)
{
var text = (value ?? string.Empty).Trim();
return text.Length == 0 ? null : text;
}
private static int SelectNextAccountIndex(List<AccountSlot> slots, ref int queueIndex)
{
if (slots.Count == 0)
return -1;
if (queueIndex < 0 || queueIndex >= slots.Count)
queueIndex = 0;
var selected = queueIndex;
queueIndex = (queueIndex + 1) % slots.Count;
return selected;
}
private static int SelectNextTargetIndex(AccountSlot slot, Dictionary<int, int> targetQueueIndexByAccountId)
{
if (slot.Targets.Count == 0)
return -1;
if (!targetQueueIndexByAccountId.TryGetValue(slot.Account.Id, out var index))
index = 0;
if (index < 0 || index >= slot.Targets.Count)
index = 0;
var selected = index;
targetQueueIndexByAccountId[slot.Account.Id] = (selected + 1) % slot.Targets.Count;
return selected;
}
private static int NextDelayMilliseconds(int min, int max)
{
if (min <= 0 && max <= 0)
return 0;
if (max <= min)
return Math.Max(0, min);
return Random.Shared.Next(min, max + 1);
}
private static bool ShouldPersistProgress(int completed, bool hadFailureThisRound, DateTime lastPersistAtUtc)
{
if (completed <= 0)
return hadFailureThisRound;
if (hadFailureThisRound)
return true;
if (completed % 5 == 0)
return true;
return (DateTime.UtcNow - lastPersistAtUtc) >= TimeSpan.FromSeconds(10);
}
private static async Task<bool> DelayWithTaskCheckAsync(IModuleTaskExecutionHost host, int delayMs, CancellationToken cancellationToken)
{
if (delayMs <= 0)
return true;
var remaining = delayMs;
while (remaining > 0)
{
cancellationToken.ThrowIfCancellationRequested();
if (!await host.IsStillRunningAsync(cancellationToken))
return false;
var chunk = Math.Min(remaining, 1000);
await Task.Delay(chunk, cancellationToken);
remaining -= chunk;
}
return true;
}
private static void AddFailure(MessageReportTaskConfig config, Account account, string messageLink, string reason)
{
config.RecentFailures ??= new List<MessageReportTaskRuntimeFailure>();
config.RecentFailures.Insert(0, new MessageReportTaskRuntimeFailure
{
TimeUtc = DateTime.UtcNow,
AccountId = account.Id,
Account = BuildAccountDisplayName(account),
MessageLink = (messageLink ?? string.Empty).Trim(),
Reason = reason
});
if (config.RecentFailures.Count > MaxFailureLines)
config.RecentFailures = config.RecentFailures.Take(MaxFailureLines).ToList();
}
private static string BuildAccountDisplayName(Account account)
{
var username = string.IsNullOrWhiteSpace(account.Username) ? string.Empty : $"@{account.Username}";
var nickname = string.IsNullOrWhiteSpace(account.Nickname) ? string.Empty : account.Nickname.Trim();
return string.Join(" / ", new[] { account.DisplayPhone, username, nickname }.Where(x => !string.IsNullOrWhiteSpace(x)));
}
private static string NormalizeReason(string? reason)
{
var text = (reason ?? string.Empty).Trim();
return text.Length == 0 ? "未知错误" : text;
}
private static string SerializeIndented(MessageReportTaskConfig config)
=> JsonSerializer.Serialize(config, new JsonSerializerOptions { WriteIndented = true });
private sealed class AccountSlot
{
public AccountSlot(Account account)
{
Account = account;
}
public Account Account { get; }
public List<TargetSlot> Targets { get; } = new();
}
private sealed class TargetSlot
{
public TargetSlot(TelegramMessageReportService.ResolvedReportMessageTarget target)
{
Target = target;
}
public TelegramMessageReportService.ResolvedReportMessageTarget Target { get; }
public int ConsecutiveFailures { get; set; }
}
}
@@ -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<int> CategoryIds { get; set; } = new();
[JsonPropertyName("category_names")]
public List<string> CategoryNames { get; set; } = new();
[JsonPropertyName("message_links")]
public List<string> MessageLinks { get; set; } = new();
[JsonPropertyName("delay_min_ms")]
public int DelayMinMs { get; set; } = 15000;
[JsonPropertyName("delay_max_ms")]
public int DelayMaxMs { get; set; } = 45000;
[JsonPropertyName("max_reports")]
public int MaxReports { get; set; }
[JsonPropertyName("report_preset")]
public string ReportPreset { get; set; } = MessageReportTaskPresets.Spam;
[JsonPropertyName("option_keywords")]
public List<string> OptionKeywords { get; set; } = new();
[JsonPropertyName("comment")]
public string? Comment { get; set; }
[JsonPropertyName("canceled")]
public bool Canceled { get; set; }
[JsonPropertyName("error")]
public string? Error { get; set; }
[JsonPropertyName("recent_failures")]
public List<MessageReportTaskRuntimeFailure> RecentFailures { get; set; } = new();
}
public sealed class MessageReportTaskRuntimeFailure
{
[JsonPropertyName("time_utc")]
public DateTime TimeUtc { get; set; }
[JsonPropertyName("account_id")]
public int AccountId { get; set; }
[JsonPropertyName("account")]
public string Account { get; set; } = string.Empty;
[JsonPropertyName("message_link")]
public string MessageLink { get; set; } = string.Empty;
[JsonPropertyName("reason")]
public string Reason { get; set; } = string.Empty;
}
@@ -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<MessageReportTaskConfig>(raw)
?? throw new InvalidOperationException("任务配置解析结果为空");
}
catch (Exception ex)
{
throw new InvalidOperationException($"任务配置 JSON 无效:{ex.Message}");
}
cfg.CategoryIds = NormalizeCategoryIds(cfg.CategoryIds, cfg.CategoryId);
if (cfg.CategoryIds.Count == 0)
throw new InvalidOperationException("任务缺少账号分类,无法重新运行");
cfg.CategoryId = cfg.CategoryIds[0];
cfg.CategoryNames = NormalizeCategoryNames(cfg.CategoryNames, cfg.CategoryName);
cfg.CategoryName = cfg.CategoryNames.FirstOrDefault() ?? cfg.CategoryName;
cfg.MessageLinks = (cfg.MessageLinks ?? new List<string>())
.Select(x => (x ?? string.Empty).Trim())
.Where(x => x.Length > 0)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (cfg.MessageLinks.Count == 0)
throw new InvalidOperationException("任务缺少消息链接,无法重新运行");
cfg.OptionKeywords = (cfg.OptionKeywords ?? new List<string>())
.Select(x => (x ?? string.Empty).Trim())
.Where(x => x.Length > 0)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (cfg.DelayMinMs < 0) cfg.DelayMinMs = 0;
if (cfg.DelayMaxMs < 0) cfg.DelayMaxMs = 0;
if (cfg.DelayMinMs > 86400000) cfg.DelayMinMs = 86400000;
if (cfg.DelayMaxMs > 86400000) cfg.DelayMaxMs = 86400000;
if (cfg.DelayMaxMs < cfg.DelayMinMs) cfg.DelayMaxMs = cfg.DelayMinMs;
if (cfg.MaxReports < 0) cfg.MaxReports = 0;
cfg.ReportPreset = NormalizePreset(cfg.ReportPreset);
cfg.Comment = NormalizeNullableText(cfg.Comment);
cfg.Canceled = false;
cfg.Error = null;
cfg.RecentFailures = new List<MessageReportTaskRuntimeFailure>();
return cfg;
}
private static List<int> NormalizeCategoryIds(IEnumerable<int>? values, int fallback)
{
var ids = (values ?? Array.Empty<int>())
.Where(x => x > 0)
.Distinct()
.ToList();
if (ids.Count == 0 && fallback > 0)
ids.Add(fallback);
return ids;
}
private static List<string> NormalizeCategoryNames(IEnumerable<string>? values, string? fallback)
{
var names = (values ?? Array.Empty<string>())
.Select(x => (x ?? string.Empty).Trim())
.Where(x => x.Length > 0)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
var fallbackName = (fallback ?? string.Empty).Trim();
if (names.Count == 0 && fallbackName.Length > 0)
names.Add(fallbackName);
return names;
}
private static string NormalizePreset(string? preset)
{
var value = (preset ?? string.Empty).Trim().ToLowerInvariant();
return value switch
{
MessageReportTaskPresets.Spam => MessageReportTaskPresets.Spam,
MessageReportTaskPresets.Violence => MessageReportTaskPresets.Violence,
MessageReportTaskPresets.Pornography => MessageReportTaskPresets.Pornography,
MessageReportTaskPresets.ChildAbuse => MessageReportTaskPresets.ChildAbuse,
MessageReportTaskPresets.Copyright => MessageReportTaskPresets.Copyright,
MessageReportTaskPresets.IllegalDrugs => MessageReportTaskPresets.IllegalDrugs,
MessageReportTaskPresets.PersonalDetails => MessageReportTaskPresets.PersonalDetails,
MessageReportTaskPresets.Other => MessageReportTaskPresets.Other,
MessageReportTaskPresets.FirstAvailable => MessageReportTaskPresets.FirstAvailable,
MessageReportTaskPresets.Custom => MessageReportTaskPresets.Custom,
_ => MessageReportTaskPresets.Spam
};
}
private static string? NormalizeNullableText(string? value)
{
var text = (value ?? string.Empty).Trim();
return text.Length == 0 ? null : text;
}
}
@@ -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<TelegramMessageReportService> _logger;
public TelegramMessageReportService(
AccountTelegramToolsService accountTools,
ITelegramClientPool clientPool,
IConfiguration configuration,
ILogger<TelegramMessageReportService> logger)
{
_accountTools = accountTools;
_clientPool = clientPool;
_configuration = configuration;
_logger = logger;
}
public sealed record ResolvedReportMessageTarget(
AccountTelegramToolsService.ResolvedChatTarget Chat,
int MessageId,
string CanonicalLink,
string RawInput);
public sealed record ReportMessageResult(
bool Success,
string? Error,
string? SelectedOptionText,
bool ShouldRemoveTarget);
private sealed record ParsedMessageLink(string ResolveTarget, int MessageId, string CanonicalLink);
public async Task<(bool Success, string? Error, ResolvedReportMessageTarget? Target)> ResolveMessageTargetAsync(
int accountId,
string rawInput,
CancellationToken cancellationToken = default)
{
try
{
var parsed = ParseMessageLink(rawInput);
var resolved = await _accountTools.ResolveChatTargetAsync(accountId, parsed.ResolveTarget, cancellationToken);
if (!resolved.Success || resolved.Target == null)
return (false, resolved.Error ?? "无法解析消息链接", null);
return (true, null, new ResolvedReportMessageTarget(resolved.Target, parsed.MessageId, parsed.CanonicalLink, (rawInput ?? string.Empty).Trim()));
}
catch (Exception ex)
{
var message = ex is ArgumentException or FormatException or InvalidOperationException
? ex.Message
: BuildTelegramError(ex);
return (false, message, null);
}
}
public async Task<ReportMessageResult> ReportMessageAsync(
int accountId,
ResolvedReportMessageTarget target,
MessageReportTaskConfig config,
CancellationToken cancellationToken = default)
{
try
{
var client = await EnsureConnectedClientAsync(accountId, target.Chat.CanonicalId, cancellationToken);
byte[] option = Array.Empty<byte>();
var comment = string.Empty;
string? selectedOptionText = null;
var result = await ExecuteTelegramRequestAsync(
accountId,
$"举报消息({target.CanonicalLink})",
() => client.Messages_Report(target.Chat.Peer, new[] { target.MessageId }, option, comment),
cancellationToken,
resetClientOnTimeout: true);
for (var depth = 0; depth < 8; depth++)
{
cancellationToken.ThrowIfCancellationRequested();
switch (result)
{
case ReportResultReported:
return new ReportMessageResult(true, null, selectedOptionText, true);
case ReportResultChooseOption choose:
{
var selected = SelectOption(choose.options, config);
if (selected == null)
{
var available = BuildOptionListText(choose.options);
var error = available.Length == 0
? "Telegram 返回了空的举报选项列表"
: $"未匹配到举报类型,对应可选项:{available}";
return new ReportMessageResult(false, error, selectedOptionText, true);
}
option = selected.option ?? Array.Empty<byte>();
selectedOptionText = NormalizeNullableText(selected.text);
result = await ExecuteTelegramRequestAsync(
accountId,
$"选择举报类型({selectedOptionText ?? "未命名选项"})",
() => client.Messages_Report(target.Chat.Peer, new[] { target.MessageId }, option, comment),
cancellationToken,
resetClientOnTimeout: true);
break;
}
case ReportResultAddComment addComment:
{
option = addComment.option ?? option;
comment = BuildComment(config.Comment, target);
var optional = addComment.flags.HasFlag(ReportResultAddComment.Flags.optional);
if (string.IsNullOrWhiteSpace(comment) && !optional)
return new ReportMessageResult(false, "当前举报类型要求填写举报文案,请先补充文案后再执行", selectedOptionText, true);
result = await ExecuteTelegramRequestAsync(
accountId,
$"提交举报文案({target.CanonicalLink})",
() => client.Messages_Report(target.Chat.Peer, new[] { target.MessageId }, option, comment),
cancellationToken,
resetClientOnTimeout: true);
break;
}
default:
return new ReportMessageResult(false, $"不支持的举报返回类型:{result.GetType().Name}", selectedOptionText, true);
}
}
return new ReportMessageResult(false, "举报流程超过安全重试次数,请稍后再试", selectedOptionText, false);
}
catch (Exception ex)
{
var error = BuildTelegramError(ex);
return new ReportMessageResult(false, error, null, ShouldRemoveTargetAfterFailure(error));
}
}
private async Task<Client> EnsureConnectedClientAsync(int accountId, string resolveHint, CancellationToken cancellationToken)
{
var existing = _clientPool.GetClient(accountId);
if (existing?.User != null)
return existing;
var resolved = await _accountTools.ResolveChatTargetAsync(accountId, resolveHint, cancellationToken);
if (!resolved.Success)
throw new InvalidOperationException(resolved.Error ?? "无法建立 Telegram 客户端连接");
var client = _clientPool.GetClient(accountId);
if (client?.User != null)
return client;
throw new InvalidOperationException("无法建立 Telegram 客户端连接");
}
private static ParsedMessageLink ParseMessageLink(string rawInput)
{
var raw = (rawInput ?? string.Empty).Trim();
if (raw.Length == 0)
throw new ArgumentException("消息链接为空", nameof(rawInput));
if (raw.StartsWith("tg://", StringComparison.OrdinalIgnoreCase)
&& Uri.TryCreate(raw, UriKind.Absolute, out var tgUri)
&& string.Equals(tgUri.Host, "privatepost", StringComparison.OrdinalIgnoreCase))
{
var query = ParseQueryString(tgUri.Query);
if (!query.TryGetValue("channel", out var channelText) || string.IsNullOrWhiteSpace(channelText))
throw new ArgumentException("tg://privatepost 缺少 channel 参数", nameof(rawInput));
if (!query.TryGetValue("post", out var postText) || string.IsNullOrWhiteSpace(postText))
throw new ArgumentException("tg://privatepost 缺少 post 参数", nameof(rawInput));
var internalId = NormalizeInternalChatId(channelText);
var messageId = ParsePositiveMessageId(postText);
return new ParsedMessageLink($"-100{internalId}", messageId, $"https://t.me/c/{internalId}/{messageId}");
}
var normalizedUrl = raw;
if (normalizedUrl.StartsWith("t.me/", StringComparison.OrdinalIgnoreCase)
|| normalizedUrl.StartsWith("telegram.me/", StringComparison.OrdinalIgnoreCase))
{
normalizedUrl = "https://" + normalizedUrl;
}
if (Uri.TryCreate(normalizedUrl, UriKind.Absolute, out var uri)
&& (string.Equals(uri.Host, "t.me", StringComparison.OrdinalIgnoreCase)
|| string.Equals(uri.Host, "telegram.me", StringComparison.OrdinalIgnoreCase)))
{
var segments = (uri.AbsolutePath ?? string.Empty)
.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
return ParseSegments(segments, rawInput);
}
var plain = raw;
var queryIndex = plain.IndexOfAny(new[] { '?', '#' });
if (queryIndex >= 0)
plain = plain[..queryIndex];
var pathSegments = plain
.Trim('/')
.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
return ParseSegments(pathSegments, rawInput);
}
private static ParsedMessageLink ParseSegments(string[] segments, string? rawInput)
{
if (segments.Length >= 3 && string.Equals(segments[0], "c", StringComparison.OrdinalIgnoreCase))
{
var internalId = NormalizeInternalChatId(segments[1]);
var messageId = ParsePositiveMessageId(segments[2]);
return new ParsedMessageLink($"-100{internalId}", messageId, $"https://t.me/c/{internalId}/{messageId}");
}
if (segments.Length >= 3 && (string.Equals(segments[0], "b", StringComparison.OrdinalIgnoreCase)
|| string.Equals(segments[0], "s", StringComparison.OrdinalIgnoreCase)))
{
var target = NormalizeTargetToken(segments[1]);
var messageId = ParsePositiveMessageId(segments[2]);
return new ParsedMessageLink(target, messageId, $"https://t.me/{target.TrimStart('@')}/{messageId}");
}
if (segments.Length >= 2)
{
var target = NormalizeTargetToken(segments[0]);
var messageId = ParsePositiveMessageId(segments[1]);
if (long.TryParse(target, NumberStyles.Integer, CultureInfo.InvariantCulture, out _))
return new ParsedMessageLink(target, messageId, $"{target}/{messageId}");
return new ParsedMessageLink(target, messageId, $"https://t.me/{target.TrimStart('@')}/{messageId}");
}
throw new ArgumentException($"不支持的消息链接格式:{(rawInput ?? string.Empty).Trim()}", nameof(rawInput));
}
private static string NormalizeInternalChatId(string value)
{
var text = (value ?? string.Empty).Trim();
if (text.StartsWith("-100", StringComparison.Ordinal))
text = text[4..];
if (!long.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) || parsed <= 0)
throw new FormatException($"私有消息链接中的频道 ID 无效:{value}");
return parsed.ToString(CultureInfo.InvariantCulture);
}
private static string NormalizeTargetToken(string value)
{
var text = (value ?? string.Empty).Trim();
if (text.Length == 0)
throw new FormatException("消息链接中缺少目标标识");
if (long.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out _))
return text;
return text.TrimStart('@');
}
private static int ParsePositiveMessageId(string value)
{
var text = (value ?? string.Empty).Trim();
if (!int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) || parsed <= 0)
throw new FormatException($"消息 ID 无效:{value}");
return parsed;
}
private static Dictionary<string, string> ParseQueryString(string query)
{
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var text = (query ?? string.Empty).Trim();
if (text.StartsWith("?", StringComparison.Ordinal))
text = text[1..];
if (text.Length == 0)
return result;
foreach (var pair in text.Split('&', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
var parts = pair.Split('=', 2);
var key = Uri.UnescapeDataString((parts[0] ?? string.Empty).Trim());
if (key.Length == 0)
continue;
var value = parts.Length > 1 ? Uri.UnescapeDataString((parts[1] ?? string.Empty).Trim()) : string.Empty;
result[key] = value;
}
return result;
}
private static MessageReportOption? SelectOption(IEnumerable<MessageReportOption>? options, MessageReportTaskConfig config)
{
var list = (options ?? Array.Empty<MessageReportOption>())
.Where(x => x != null)
.ToList();
if (list.Count == 0)
return null;
if (string.Equals(config.ReportPreset, MessageReportTaskPresets.FirstAvailable, StringComparison.OrdinalIgnoreCase))
return list[0];
var normalizedKeywords = BuildNormalizedKeywords(config);
if (normalizedKeywords.Count == 0)
return null;
foreach (var option in list)
{
var text = NormalizeKeyword(option.text);
if (text.Length == 0)
continue;
if (normalizedKeywords.Any(keyword => text.Contains(keyword, StringComparison.Ordinal)))
return option;
}
return null;
}
private static List<string> BuildNormalizedKeywords(MessageReportTaskConfig config)
{
var custom = (config.OptionKeywords ?? new List<string>())
.Select(NormalizeKeyword)
.Where(x => x.Length > 0)
.Distinct(StringComparer.Ordinal)
.ToList();
if (custom.Count > 0)
return custom;
return GetPresetKeywords(config.ReportPreset)
.Select(NormalizeKeyword)
.Where(x => x.Length > 0)
.Distinct(StringComparer.Ordinal)
.ToList();
}
private static IEnumerable<string> GetPresetKeywords(string? preset)
{
return (preset ?? string.Empty).Trim().ToLowerInvariant() switch
{
MessageReportTaskPresets.Spam => new[] { "spam", "垃圾", "骚扰", "广告" },
MessageReportTaskPresets.Violence => new[] { "violence", "violent", "暴力", "威胁" },
MessageReportTaskPresets.Pornography => new[] { "porn", "pornography", "色情", "淫秽" },
MessageReportTaskPresets.ChildAbuse => new[] { "child", "childabuse", "儿童", "未成年人" },
MessageReportTaskPresets.Copyright => new[] { "copyright", "侵权", "版权" },
MessageReportTaskPresets.IllegalDrugs => new[] { "drugs", "illegaldrugs", "毒品", "违禁药物" },
MessageReportTaskPresets.PersonalDetails => new[] { "personal", "personaldetails", "隐私", "个人信息" },
MessageReportTaskPresets.Other => new[] { "other", "其他" },
_ => Array.Empty<string>()
};
}
private static string BuildComment(string? template, ResolvedReportMessageTarget target)
{
var text = (template ?? string.Empty).Trim();
if (text.Length == 0)
return string.Empty;
return text
.Replace("{消息链接}", target.CanonicalLink, StringComparison.OrdinalIgnoreCase)
.Replace("{message_link}", target.CanonicalLink, StringComparison.OrdinalIgnoreCase)
.Replace("{聊天标题}", target.Chat.Title, StringComparison.OrdinalIgnoreCase)
.Replace("{chat_title}", target.Chat.Title, StringComparison.OrdinalIgnoreCase)
.Replace("{消息ID}", target.MessageId.ToString(CultureInfo.InvariantCulture), StringComparison.OrdinalIgnoreCase)
.Replace("{message_id}", target.MessageId.ToString(CultureInfo.InvariantCulture), StringComparison.OrdinalIgnoreCase);
}
private static string BuildOptionListText(IEnumerable<MessageReportOption>? options)
{
return string.Join(" / ", (options ?? Array.Empty<MessageReportOption>())
.Select(x => NormalizeNullableText(x?.text))
.Where(x => !string.IsNullOrWhiteSpace(x))
.Distinct(StringComparer.OrdinalIgnoreCase));
}
private static string NormalizeKeyword(string? value)
{
if (string.IsNullOrWhiteSpace(value))
return string.Empty;
var builder = new StringBuilder(value.Length);
foreach (var ch in value.Trim().ToLowerInvariant())
{
if (char.IsLetterOrDigit(ch) || ch >= 0x4e00)
builder.Append(ch);
}
return builder.ToString();
}
private static string? NormalizeNullableText(string? value)
{
var text = (value ?? string.Empty).Trim();
return text.Length == 0 ? null : text;
}
private string BuildTelegramError(Exception ex)
{
var (summary, details) = AccountTelegramToolsService.MapTelegramException(ex);
return string.IsNullOrWhiteSpace(details) ? summary : $"{summary}:{details}";
}
private static bool ShouldRemoveTargetAfterFailure(string error)
{
if (string.IsNullOrWhiteSpace(error))
return false;
if (LooksLikeTemporaryFailure(error))
return false;
return error.Contains("ALREADY_REPORTED", StringComparison.OrdinalIgnoreCase)
|| error.Contains("MESSAGE_ALREADY_REPORTED", StringComparison.OrdinalIgnoreCase)
|| error.Contains("MESSAGE_ID_INVALID", StringComparison.OrdinalIgnoreCase)
|| error.Contains("MSG_ID_INVALID", StringComparison.OrdinalIgnoreCase)
|| error.Contains("MESSAGE_NOT_FOUND", StringComparison.OrdinalIgnoreCase)
|| error.Contains("CHAT_ID_INVALID", StringComparison.OrdinalIgnoreCase)
|| error.Contains("PEER_ID_INVALID", StringComparison.OrdinalIgnoreCase)
|| error.Contains("CHANNEL_INVALID", StringComparison.OrdinalIgnoreCase)
|| error.Contains("CHANNEL_PRIVATE", StringComparison.OrdinalIgnoreCase)
|| error.Contains("GROUPED_MEDIA_INVALID", StringComparison.OrdinalIgnoreCase)
|| error.Contains("未匹配到举报类型", StringComparison.OrdinalIgnoreCase)
|| error.Contains("要求填写举报文案", StringComparison.OrdinalIgnoreCase)
|| error.Contains("不支持的举报返回类型", StringComparison.OrdinalIgnoreCase)
|| error.Contains("链接格式", StringComparison.OrdinalIgnoreCase);
}
private static bool LooksLikeTemporaryFailure(string error)
{
return error.Contains("FLOOD_WAIT", StringComparison.OrdinalIgnoreCase)
|| error.Contains("请求超时", StringComparison.OrdinalIgnoreCase)
|| error.Contains("timeout", StringComparison.OrdinalIgnoreCase)
|| error.Contains("timed out", StringComparison.OrdinalIgnoreCase)
|| error.Contains("网络", StringComparison.OrdinalIgnoreCase)
|| error.Contains("NETWORK", StringComparison.OrdinalIgnoreCase);
}
private TimeSpan GetTelegramRequestTimeout()
{
var seconds = int.TryParse(_configuration["Telegram:RequestTimeoutSeconds"], out var parsedSeconds)
? parsedSeconds
: 90;
return TimeSpan.FromSeconds(Math.Clamp(seconds, 15, 600));
}
private async Task<T> ExecuteTelegramRequestAsync<T>(
int accountId,
string operation,
Func<Task<T>> action,
CancellationToken cancellationToken,
bool resetClientOnTimeout)
{
var timeout = GetTelegramRequestTimeout();
try
{
return await action().WaitAsync(timeout, cancellationToken);
}
catch (TimeoutException)
{
_logger.LogWarning(
"Telegram request timed out after {TimeoutSeconds}s for account {AccountId}: {Operation}",
timeout.TotalSeconds,
accountId,
operation);
if (resetClientOnTimeout)
await _clientPool.RemoveClientAsync(accountId);
throw new TimeoutException($"Telegram 请求超时:{operation} 超过 {timeout.TotalSeconds:0} 秒,可能是账号受限、网络异常或代理异常");
}
}
}
@@ -0,0 +1,33 @@
<Project Sdk="Microsoft.NET.Sdk.Razor">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>TelegramPanel.Modules.MessageReportTask</RootNamespace>
<AssemblyName>TelegramPanel.Modules.MessageReportTask</AssemblyName>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\TelegramPanel.Modules.Abstractions\TelegramPanel.Modules.Abstractions.csproj" />
<ProjectReference Include="..\TelegramPanel.Core\TelegramPanel.Core.csproj" />
<ProjectReference Include="..\TelegramPanel.Data\TelegramPanel.Data.csproj" />
<PackageReference Include="MudBlazor" Version="7.*" />
<PackageReference Include="WTelegramClient" Version="4.*" />
</ItemGroup>
<ItemGroup>
<Content Remove="publish/**" />
<None Remove="publish/**" />
</ItemGroup>
<ItemGroup>
<None Update="manifest.json">
<CopyToOutputDirectory>Never</CopyToOutputDirectory>
<CopyToPublishDirectory>Never</CopyToPublishDirectory>
</None>
<Content Update="manifest.json">
<CopyToOutputDirectory>Never</CopyToOutputDirectory>
<CopyToPublishDirectory>Never</CopyToPublishDirectory>
</Content>
</ItemGroup>
</Project>
@@ -0,0 +1,5 @@
@using Microsoft.AspNetCore.Components
@using MudBlazor
@using TelegramPanel.Core.Services
@using TelegramPanel.Data.Entities
@using TelegramPanel.Modules
@@ -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"
}
}
@@ -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
<MudDialog>
<DialogContent>
<MudStack Spacing="2">
<MudAlert Severity="Severity.Warning" Variant="Variant.Outlined" Dense="true">
将对选中的账号批量修改头像。支持固定上传单个头像,或选择图片字典变量按字典读取方式为每个账号取图。
</MudAlert>
<MudText Typo="Typo.subtitle2">账号数量:@accounts.Count</MudText>
<MudSelect T="string" @bind-Value="avatarSource" Label="头像来源" Variant="Variant.Outlined">
<MudSelectItem Value="@AvatarSourceFixed">固定上传</MudSelectItem>
<MudSelectItem Value="@AvatarSourceDictionary">图片字典变量</MudSelectItem>
</MudSelect>
@if (IsFixedSource)
{
<MudFileUpload T="IReadOnlyList<IBrowserFile>" FilesChanged="OnAvatarChanged" Accept="image/*" MaximumFileCount="1">
<ActivatorContent>
<MudButton Variant="Variant.Outlined" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Image">
选择头像图片
</MudButton>
</ActivatorContent>
</MudFileUpload>
@if (fixedAvatarBytes is { Length: > 0 })
{
<MudText Typo="Typo.body2">已选择:@fixedAvatarFileName(@fixedAvatarBytes.Length bytes)</MudText>
}
}
else
{
<MudSelect T="string" @bind-Value="selectedImageDictionaryName" Label="图片字典" Variant="Variant.Outlined">
<MudSelectItem Value="@string.Empty">-- 请选择图片字典 --</MudSelectItem>
@foreach (var item in imageDictionaryOptions)
{
<MudSelectItem Value="@item.Name">@item.DisplayText</MudSelectItem>
}
</MudSelect>
@if (imageDictionaryOptions.Count > 0)
{
<MudText Typo="Typo.caption">可用图片变量:@string.Join("、", imageDictionaryOptions.Select(x => $"{{{x.Name}}}"))</MudText>
}
}
@if (running || results.Count > 0)
{
<MudDivider Class="my-2" />
<MudStack direction="Row" AlignItems="AlignItems.Center" Spacing="2">
<MudText Typo="Typo.body2">进度:@processed / @accounts.Count</MudText>
<MudText Typo="Typo.body2" Color="Color.Success">成功:@success</MudText>
<MudText Typo="Typo.body2" Color="Color.Error">失败:@failed</MudText>
@if (running)
{
<MudProgressCircular Indeterminate="true" Size="Size.Small" />
}
</MudStack>
<MudTable Items="@results" Dense="true" Hover="true" Breakpoint="Breakpoint.Sm">
<HeaderContent>
<MudTh>账号</MudTh>
<MudTh>头像来源</MudTh>
<MudTh>结果</MudTh>
<MudTh>原因</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="账号">@context.Phone</MudTd>
<MudTd DataLabel="头像来源">@context.Source</MudTd>
<MudTd DataLabel="结果">
<MudChip T="string" Size="Size.Small" Color="@(context.Success ? Color.Success : Color.Error)">
@(context.Success ? "成功" : "失败")
</MudChip>
</MudTd>
<MudTd DataLabel="原因">@((context.Error ?? "").Trim())</MudTd>
</RowTemplate>
</MudTable>
}
</MudStack>
</DialogContent>
<DialogActions>
<MudButton Variant="Variant.Text" Color="Color.Default" OnClick="Cancel" Disabled="@running">关闭</MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="Start" Disabled="@running">
@if (running)
{
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="mr-2" />
<span>执行中...</span>
}
else
{
<span>开始修改</span>
}
</MudButton>
</DialogActions>
</MudDialog>
@code
{
private const string AvatarSourceFixed = "fixed";
private const string AvatarSourceDictionary = "dictionary";
[CascadingParameter] private MudDialogInstance MudDialog { get; set; } = default!;
[Parameter] public IReadOnlyList<int> AccountIds { get; set; } = Array.Empty<int>();
private List<Account> accounts = new();
private List<ImageDictionaryOption> 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<ResultRow> 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<int>();
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<IBrowserFile> 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<TelegramPanel.Web.Components.Dialogs.RiskWarningDialog>("批量操作风控警告", 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);
}
@@ -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
<MudDialog>
<DialogContent>
<MudStack Spacing="2">
<MudAlert Severity="Severity.Warning" Variant="Variant.Outlined" Dense="true">
将对选中的账号批量修改昵称。支持两种模式:单个“批量昵称”(可自动追加手机号后 4 位),或“昵称列表”(按行轮询分配)。
将对选中的账号批量修改昵称。支持两种模式:单个“批量昵称”或“昵称列表”。这两个输入都支持 `{time}` 和文本字典变量。
</MudAlert>
<MudText Typo="Typo.subtitle2">账号数量:@accounts.Count</MudText>
<MudTextField @bind-Value="baseNickname" Label="批量昵称" Variant="Variant.Outlined" Required="true"
HelperText="单昵称模式:例如“测试号”(将生成:测试号1234 / 测试号5678 ...)。若填写了“昵称列表”,则优先使用昵称列表。" />
HelperText="单昵称模式:例如“测试号{city}”。若填写了“昵称列表”,则优先使用昵称列表。" />
<MudSwitch @bind-Value="appendPhoneLast4" Label="自动追加手机号后 4 位(单昵称模式)" Color="Color.Primary" />
<MudTextField @bind-Value="nicknameLines" Label="昵称列表(换行分隔)" Variant="Variant.Outlined" Lines="6"
HelperText="每行一个昵称;会按顺序依次分配给已选账号;昵称用完后从头开始轮询。" />
HelperText="每行一个昵称模板;按顺序分配给已选账号;昵称用完后从头轮询。每行同样支持 `{time}` 和文本字典变量。" />
<MudSwitch @bind-Value="appendPhoneLast4WhenDuplicate" Label="昵称重复时追加手机号后 4 位(昵称列表模式)" Color="Color.Primary" />
<MudText Typo="Typo.caption" Class="mud-text-secondary">
例如:昵称列表只有 1 行或轮询后重复时,为避免重复昵称可追加手机号后 4 位。
</MudText>
@if (textDictionaryNames.Count > 0)
{
<MudText Typo="Typo.caption">可用文本变量:@string.Join("、", textDictionaryNames.Select(x => $"{{{x}}}"))</MudText>
}
@if (running || results.Count > 0)
{
<MudDivider Class="my-2" />
@@ -82,7 +90,11 @@
[CascadingParameter] private MudDialogInstance MudDialog { get; set; } = default!;
[Parameter] public IReadOnlyList<int> AccountIds { get; set; } = Array.Empty<int>();
private static readonly Regex TemplateTokenRegex = new(@"\{(?<name>[a-zA-Z0-9_]+)\}", RegexOptions.Compiled);
private readonly HashSet<string> supportedTextTokens = new(StringComparer.OrdinalIgnoreCase);
private List<Account> accounts = new();
private List<string> 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<string> { 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<TelegramPanel.Web.Components.Dialogs.RiskWarningDialog>("批量操作风控警告", 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<string, int>(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<string> 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);
}
@@ -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 @@
<DialogContent>
<MudStack Spacing="2">
<MudAlert Severity="Severity.Warning" Variant="Variant.Outlined" Dense="true">
将对选中的账号批量修改用户名(t.me/xxx)。用户名模板支持变量:
<MudText Typo="Typo.body2" Class="mt-1">
<b>{number}</b> / <b>{letter}</b> 表示 1 位随机数字/字母;<b>{number3}</b> 表示 3 位随机数字;<b>{letter4}</b> 表示 4 位随机字母。可组合使用。
</MudText>
<MudText Typo="Typo.body2" Class="mt-1">
示例:<b>test_{number3}{letter4}</b> → <b>test_123abcd</b>
</MudText>
将对选中的账号批量修改用户名(t.me/xxx)。用户名模板支持 `{time}` 和文本字典变量;批量执行时建议使用文本字典提供不同值,避免重复。
</MudAlert>
<MudText Typo="Typo.subtitle2">账号数量:@accounts.Count</MudText>
<MudTextField @bind-Value="usernameTemplate" Label="用户名模板" Variant="Variant.Outlined" Required="true"
HelperText="请输入用户名模板(不要带开头的 at 符号)。例如:test_{number3}{letter4}" />
HelperText="请输入用户名模板(不要带开头的 at 符号)。例如:tg_{city}_{time}" />
@if (textDictionaryNames.Count > 0)
{
<MudText Typo="Typo.caption">可用文本变量:@string.Join("、", textDictionaryNames.Select(x => $"{{{x}}}"))</MudText>
}
@if (running || results.Count > 0)
{
@@ -82,11 +82,12 @@
[CascadingParameter] private MudDialogInstance MudDialog { get; set; } = default!;
[Parameter] public IReadOnlyList<int> AccountIds { get; set; } = Array.Empty<int>();
private static readonly Regex SupportedTokenRegex = new(@"\{(?<kind>number|nuber|letter)(?<len>\d*)\}", RegexOptions.Compiled | RegexOptions.IgnoreCase);
private static readonly Regex AnyTokenRegex = new(@"\{[^}]*\}", RegexOptions.Compiled);
private static readonly Regex TemplateTokenRegex = new(@"\{(?<name>[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<string> supportedTextTokens = new(StringComparer.OrdinalIgnoreCase);
private List<Account> accounts = new();
private List<string> 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<TelegramPanel.Web.Components.Dialogs.RiskWarningDialog>("批量操作风控警告", 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<string>(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<string> 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();
@@ -213,6 +213,7 @@
<MudMenuItem OnClick="OpenBatchChangeTwoFactorRecoveryEmail">批量换绑邮箱(找回+登录)(Cloud Mail)(已选)</MudMenuItem>
<MudMenuItem OnClick="OpenBatchSetAccountCategory">批量修改分类(已选)</MudMenuItem>
<MudMenuItem OnClick="OpenBatchChangeNickname">批量改昵称(已选)</MudMenuItem>
<MudMenuItem OnClick="OpenBatchChangeAvatar">批量改头像(已选)</MudMenuItem>
<MudMenuItem OnClick="OpenBatchChangeUsername">批量改用户名(已选)</MudMenuItem>
<MudMenuItem OnClick="OpenBatchChangeBio">批量改Bio(已选)</MudMenuItem>
</MudMenu>
@@ -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<TelegramPanel.Web.Components.Dialogs.BatchChangeAvatarDialog>("批量修改头像", parameters, options)!;
var result = await dialog.Result;
if (result is { Canceled: false })
await ReloadImportedAccountsAsync();
}
private async Task OpenBatchChangeUsername()
{
if (Busy)
@@ -84,6 +84,7 @@
<MudMenuItem Disabled="@(selectedAccounts.Count == 0)" OnClick="BatchKickAllOtherDevices">批量踢出所有其他设备(已选)</MudMenuItem>
<MudMenuItem Disabled="@(selectedAccounts.Count == 0)" OnClick="OpenBatchSetAccountCategory">批量修改分类(已选)</MudMenuItem>
<MudMenuItem Disabled="@(selectedAccounts.Count == 0)" OnClick="OpenBatchChangeNickname">批量改昵称(已选)</MudMenuItem>
<MudMenuItem Disabled="@(selectedAccounts.Count == 0)" OnClick="OpenBatchChangeAvatar">批量改头像(已选)</MudMenuItem>
<MudMenuItem Disabled="@(selectedAccounts.Count == 0)" OnClick="OpenBatchChangeUsername">批量改用户名(已选)</MudMenuItem>
<MudMenuItem Disabled="@(selectedAccounts.Count == 0)" OnClick="OpenBatchChangeBio">批量改Bio(已选)</MudMenuItem>
<MudMenuItem Disabled="@(selectedAccounts.Count == 0)" OnClick="DeleteSelectedAccounts">删除已选</MudMenuItem>
@@ -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<TelegramPanel.Web.Components.Dialogs.BatchChangeAvatarDialog>("批量修改头像", parameters, options)!;
var result = await dialog.Result;
if (result is { Canceled: false })
await ReloadTable();
}
private async Task OpenBatchChangeUsername()
{
if (loading)
@@ -8,6 +8,12 @@
<MudText Typo="Typo.h4" Class="mb-4">数据字典管理</MudText>
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Dense="true" Class="mb-4">
当前系统内置变量:<code>{time}</code>,格式为 <code>yyyyMMddHHmmss</code>,例如 <code>20260310143059</code>。
它不是 Unix 时间戳,而是可直接阅读的时间字符串。
文本字段通常支持 <code>{time}</code> 和 <code>{字典名}</code>;图片字段通常只能使用单个图片字典变量,例如 <code>{avatar}</code>。
</MudAlert>
<MudCard>
<MudCardContent>
<MudStack Row="true" Spacing="2" AlignItems="AlignItems.Center" Wrap="Wrap.Wrap">
@@ -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<string> ExtractTokenNames(string? template)
{
if (string.IsNullOrWhiteSpace(template))
return Array.Empty<string>();
var names = new List<string>();
var seen = new HashSet<string>(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<string> 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}}}");
}
}
}