From 23d88acbd36327442c971d14d4a45639e7cdce9e Mon Sep 17 00:00:00 2001 From: meoacgx Date: Mon, 9 Mar 2026 23:53:35 +0800 Subject: [PATCH 01/13] feat: add dictionaries and scheduled channel/group tasks --- .../BatchTasks/BatchTaskTypes.cs | 2 + .../Interfaces/IChannelService.cs | 5 + .../Interfaces/IGroupService.cs | 15 + .../Services/ChannelManagementService.cs | 8 +- .../Services/GroupManagementService.cs | 6 +- .../Telegram/AccountTelegramToolsService.cs | 24 +- .../Services/Telegram/BotTelegramService.cs | 29 +- .../Services/Telegram/ChannelService.cs | 39 + .../Services/Telegram/GroupService.cs | 116 +++ .../Telegram/TelegramImageProcessor.cs | 73 ++ src/TelegramPanel.Data/AppDbContext.cs | 51 + src/TelegramPanel.Data/Entities/Channel.cs | 1 + .../Entities/DataDictionary.cs | 38 + .../Entities/DataDictionaryItem.cs | 18 + src/TelegramPanel.Data/Entities/Group.cs | 1 + .../Entities/ScheduledTask.cs | 29 + ...eduledTasksAndDataDictionaries.Designer.cs | 917 ++++++++++++++++++ ...15_AddScheduledTasksAndDataDictionaries.cs | 158 +++ .../Migrations/AppDbContextModelSnapshot.cs | 172 ++++ .../DataDictionaryItemRepository.cs | 36 + .../Repositories/DataDictionaryRepository.cs | 54 ++ .../IDataDictionaryItemRepository.cs | 12 + .../Repositories/IDataDictionaryRepository.cs | 13 + .../Repositories/IScheduledTaskRepository.cs | 12 + .../Repositories/ScheduledTaskRepository.cs | 31 + .../ServiceCollectionExtensions.cs | 3 + .../ModuleContributions.cs | 16 +- .../ChannelGroupPrivateCreateTaskEditor.razor | 344 +++++++ .../ChannelGroupPublicizeTaskEditor.razor | 372 +++++++ .../Dialogs/CreateBatchTaskDialog.razor | 319 +++--- .../Dialogs/DataDictionaryEditDialog.razor | 323 ++++++ .../Dialogs/EditScheduledTaskDialog.razor | 259 +++++ .../Components/Layout/NavMenu.razor | 8 +- .../Components/Pages/ChannelCreate.razor | 10 +- .../Components/Pages/DataDictionaries.razor | 208 ++++ .../Components/Pages/GroupCreate.razor | 7 +- .../Components/Pages/Tasks.razor | 216 ++++- .../Modules/BuiltIn/TaskCatalogModule.cs | 40 + src/TelegramPanel.Web/Program.cs | 8 + .../ChannelGroupAutomationTaskModels.cs | 133 +++ .../ChannelGroupPrivateCreateTaskHandler.cs | 229 +++++ .../ChannelGroupPublicizeTaskHandler.cs | 288 ++++++ .../Services/ChannelGroupTaskDelayHelper.cs | 68 ++ .../Services/CronExpressionService.cs | 269 +++++ .../Services/DataDictionaryService.cs | 341 +++++++ .../Services/ImageAssetStorageService.cs | 175 ++++ .../ScheduledTaskBackgroundService.cs | 95 ++ .../Services/ScheduledTaskService.cs | 143 +++ .../Services/TaskAssetScopeHelper.cs | 79 ++ .../Services/TemplateRenderingService.cs | 78 ++ 50 files changed, 5696 insertions(+), 195 deletions(-) create mode 100644 src/TelegramPanel.Core/Services/Telegram/TelegramImageProcessor.cs create mode 100644 src/TelegramPanel.Data/Entities/DataDictionary.cs create mode 100644 src/TelegramPanel.Data/Entities/DataDictionaryItem.cs create mode 100644 src/TelegramPanel.Data/Entities/ScheduledTask.cs create mode 100644 src/TelegramPanel.Data/Migrations/20260309132915_AddScheduledTasksAndDataDictionaries.Designer.cs create mode 100644 src/TelegramPanel.Data/Migrations/20260309132915_AddScheduledTasksAndDataDictionaries.cs create mode 100644 src/TelegramPanel.Data/Repositories/DataDictionaryItemRepository.cs create mode 100644 src/TelegramPanel.Data/Repositories/DataDictionaryRepository.cs create mode 100644 src/TelegramPanel.Data/Repositories/IDataDictionaryItemRepository.cs create mode 100644 src/TelegramPanel.Data/Repositories/IDataDictionaryRepository.cs create mode 100644 src/TelegramPanel.Data/Repositories/IScheduledTaskRepository.cs create mode 100644 src/TelegramPanel.Data/Repositories/ScheduledTaskRepository.cs create mode 100644 src/TelegramPanel.Web/Components/Dialogs/ChannelGroupPrivateCreateTaskEditor.razor create mode 100644 src/TelegramPanel.Web/Components/Dialogs/ChannelGroupPublicizeTaskEditor.razor create mode 100644 src/TelegramPanel.Web/Components/Dialogs/DataDictionaryEditDialog.razor create mode 100644 src/TelegramPanel.Web/Components/Dialogs/EditScheduledTaskDialog.razor create mode 100644 src/TelegramPanel.Web/Components/Pages/DataDictionaries.razor create mode 100644 src/TelegramPanel.Web/Services/ChannelGroupAutomationTaskModels.cs create mode 100644 src/TelegramPanel.Web/Services/ChannelGroupPrivateCreateTaskHandler.cs create mode 100644 src/TelegramPanel.Web/Services/ChannelGroupPublicizeTaskHandler.cs create mode 100644 src/TelegramPanel.Web/Services/ChannelGroupTaskDelayHelper.cs create mode 100644 src/TelegramPanel.Web/Services/CronExpressionService.cs create mode 100644 src/TelegramPanel.Web/Services/DataDictionaryService.cs create mode 100644 src/TelegramPanel.Web/Services/ImageAssetStorageService.cs create mode 100644 src/TelegramPanel.Web/Services/ScheduledTaskBackgroundService.cs create mode 100644 src/TelegramPanel.Web/Services/ScheduledTaskService.cs create mode 100644 src/TelegramPanel.Web/Services/TaskAssetScopeHelper.cs create mode 100644 src/TelegramPanel.Web/Services/TemplateRenderingService.cs diff --git a/src/TelegramPanel.Core/BatchTasks/BatchTaskTypes.cs b/src/TelegramPanel.Core/BatchTasks/BatchTaskTypes.cs index ec2d94d..5c4b394 100644 --- a/src/TelegramPanel.Core/BatchTasks/BatchTaskTypes.cs +++ b/src/TelegramPanel.Core/BatchTasks/BatchTaskTypes.cs @@ -14,6 +14,8 @@ public static class BatchTaskTypes // User 任务(新增) public const string UserJoinSubscribe = "user_join_subscribe"; public const string UserChatActive = "user_chat_active"; + public const string ChannelGroupPrivateCreate = "channel_group_private_create"; + public const string ChannelGroupPublicize = "channel_group_publicize"; // System 任务(记录到任务中心) public const string AccountAutoSync = "account_auto_sync"; diff --git a/src/TelegramPanel.Core/Interfaces/IChannelService.cs b/src/TelegramPanel.Core/Interfaces/IChannelService.cs index 62a54d7..cceff80 100644 --- a/src/TelegramPanel.Core/Interfaces/IChannelService.cs +++ b/src/TelegramPanel.Core/Interfaces/IChannelService.cs @@ -82,6 +82,11 @@ public interface IChannelService /// Task UpdateChannelInfoAsync(int accountId, long channelId, string title, string? about); + /// + /// 设置频道头像。 + /// + Task SetChannelPhotoAsync(int accountId, long channelId, Stream fileStream, string fileName, CancellationToken cancellationToken = default); + /// /// 从频道踢出用户(通过 username),可选是否永久封禁 /// diff --git a/src/TelegramPanel.Core/Interfaces/IGroupService.cs b/src/TelegramPanel.Core/Interfaces/IGroupService.cs index c0d939d..9e46171 100644 --- a/src/TelegramPanel.Core/Interfaces/IGroupService.cs +++ b/src/TelegramPanel.Core/Interfaces/IGroupService.cs @@ -46,4 +46,19 @@ public interface IGroupService /// 获取群组管理员列表(需要权限) /// Task> GetAdminsAsync(int accountId, long groupId); + + /// + /// 更新群组标题与简介。 + /// + Task UpdateGroupInfoAsync(int accountId, long groupId, string title, string? about); + + /// + /// 设置群组公开用户名或切回私密。 + /// + Task SetGroupVisibilityAsync(int accountId, long groupId, bool isPublic, string? username = null); + + /// + /// 设置群组头像。 + /// + Task SetGroupPhotoAsync(int accountId, long groupId, Stream fileStream, string fileName, CancellationToken cancellationToken = default); } diff --git a/src/TelegramPanel.Core/Services/ChannelManagementService.cs b/src/TelegramPanel.Core/Services/ChannelManagementService.cs index 0d9b149..608f7cb 100644 --- a/src/TelegramPanel.Core/Services/ChannelManagementService.cs +++ b/src/TelegramPanel.Core/Services/ChannelManagementService.cs @@ -1,4 +1,4 @@ -using TelegramPanel.Data.Entities; +using TelegramPanel.Data.Entities; using TelegramPanel.Data.Repositories; namespace TelegramPanel.Core.Services; @@ -84,6 +84,8 @@ public class ChannelManagementService existing.CreatorAccountId = channel.CreatorAccountId; if (channel.CreatedAt.HasValue) existing.CreatedAt = channel.CreatedAt; + if (existing.SystemCreatedAtUtc == null && channel.SystemCreatedAtUtc != null) + existing.SystemCreatedAtUtc = channel.SystemCreatedAtUtc; existing.SyncedAt = DateTime.UtcNow; await _channelRepository.UpdateAsync(existing); @@ -123,6 +125,8 @@ public class ChannelManagementService legacy.GroupId = channel.GroupId; if (channel.CreatedAt.HasValue) legacy.CreatedAt = channel.CreatedAt; + if (legacy.SystemCreatedAtUtc == null && channel.SystemCreatedAtUtc != null) + legacy.SystemCreatedAtUtc = channel.SystemCreatedAtUtc; legacy.SyncedAt = DateTime.UtcNow; await _channelRepository.UpdateAsync(legacy); @@ -280,3 +284,5 @@ public class ChannelManagementService return await _accountChannelRepository.GetPreferredAdminAccountIdAsync(channel.Id); } } + + diff --git a/src/TelegramPanel.Core/Services/GroupManagementService.cs b/src/TelegramPanel.Core/Services/GroupManagementService.cs index 423fbda..a727f09 100644 --- a/src/TelegramPanel.Core/Services/GroupManagementService.cs +++ b/src/TelegramPanel.Core/Services/GroupManagementService.cs @@ -1,4 +1,4 @@ -using TelegramPanel.Data.Entities; +using TelegramPanel.Data.Entities; using TelegramPanel.Data.Repositories; namespace TelegramPanel.Core.Services; @@ -69,6 +69,8 @@ public class GroupManagementService existing.CreatorAccountId = group.CreatorAccountId; if (group.CreatedAt.HasValue) existing.CreatedAt = group.CreatedAt; + if (existing.SystemCreatedAtUtc == null && group.SystemCreatedAtUtc != null) + existing.SystemCreatedAtUtc = group.SystemCreatedAtUtc; existing.SyncedAt = DateTime.UtcNow; await _groupRepository.UpdateAsync(existing); @@ -223,3 +225,5 @@ public class GroupManagementService return await _accountGroupRepository.GetPreferredAdminAccountIdAsync(group.Id); } } + + diff --git a/src/TelegramPanel.Core/Services/Telegram/AccountTelegramToolsService.cs b/src/TelegramPanel.Core/Services/Telegram/AccountTelegramToolsService.cs index 8d2e55e..39e8318 100644 --- a/src/TelegramPanel.Core/Services/Telegram/AccountTelegramToolsService.cs +++ b/src/TelegramPanel.Core/Services/Telegram/AccountTelegramToolsService.cs @@ -1403,29 +1403,7 @@ public class AccountTelegramToolsService var client = await GetOrCreateConnectedClientAsync(accountId, cancellationToken); cancellationToken.ThrowIfCancellationRequested(); - // 先把原图读入内存,然后做“居中裁剪为正方形 + 缩放到 512x512 + JPEG 压缩”,再上传给 Telegram。 - // 这样可以避免:原图过大、长宽比异常、某些上传流读取不稳定等问题。 - await using var raw = new MemoryStream(); - if (fileStream.CanSeek) - fileStream.Position = 0; - - await fileStream.CopyToAsync(raw, cancellationToken); - raw.Position = 0; - - using var image = await Image.LoadAsync(raw, cancellationToken); - image.Mutate(x => x.AutoOrient()); - - const int targetSize = 512; - image.Mutate(x => x.Resize(new ResizeOptions - { - Mode = ResizeMode.Crop, - Size = new Size(targetSize, targetSize) - })); - - await using var encoded = new MemoryStream(); - await image.SaveAsJpegAsync(encoded, new JpegEncoder { Quality = 85 }, cancellationToken); - encoded.Position = 0; - + await using var encoded = await TelegramImageProcessor.PrepareAvatarJpegAsync(fileStream, cancellationToken); var inputFile = await client.UploadFileAsync(encoded, "avatar.jpg"); cancellationToken.ThrowIfCancellationRequested(); diff --git a/src/TelegramPanel.Core/Services/Telegram/BotTelegramService.cs b/src/TelegramPanel.Core/Services/Telegram/BotTelegramService.cs index cd987cb..a97f813 100644 --- a/src/TelegramPanel.Core/Services/Telegram/BotTelegramService.cs +++ b/src/TelegramPanel.Core/Services/Telegram/BotTelegramService.cs @@ -499,34 +499,7 @@ public class BotTelegramService if (string.IsNullOrWhiteSpace(fileName)) fileName = "photo.jpg"; - // Bot API setChatPhoto 需要 multipart 上传 InputFile。 - // 为了提高成功率:做“居中裁剪为正方形 + 缩放到 512x512 + JPEG 压缩”,避免原图过大/比例异常导致失败。 - await using var raw = new MemoryStream(); - if (fileStream.CanSeek) - fileStream.Position = 0; - await fileStream.CopyToAsync(raw, cancellationToken); - raw.Position = 0; - - await using var encoded = new MemoryStream(); - try - { - using var image = await Image.LoadAsync(raw, cancellationToken); - image.Mutate(x => x.AutoOrient()); - - const int targetSize = 512; - image.Mutate(x => x.Resize(new ResizeOptions - { - Mode = ResizeMode.Crop, - Size = new Size(targetSize, targetSize) - })); - - await image.SaveAsJpegAsync(encoded, new JpegEncoder { Quality = 85 }, cancellationToken); - encoded.Position = 0; - } - catch (UnknownImageFormatException) - { - throw new InvalidOperationException("不支持的图片格式(建议使用 JPG/PNG)"); - } + await using var encoded = await TelegramImageProcessor.PrepareAvatarJpegAsync(fileStream, cancellationToken); _ = await _api.CallWithFileAsync( token: bot.Token, diff --git a/src/TelegramPanel.Core/Services/Telegram/ChannelService.cs b/src/TelegramPanel.Core/Services/Telegram/ChannelService.cs index 905b93d..7a809af 100644 --- a/src/TelegramPanel.Core/Services/Telegram/ChannelService.cs +++ b/src/TelegramPanel.Core/Services/Telegram/ChannelService.cs @@ -868,6 +868,45 @@ public class ChannelService : IChannelService return true; } + public async Task SetChannelPhotoAsync( + int accountId, + long channelId, + Stream fileStream, + string fileName, + CancellationToken cancellationToken = default) + { + var client = await GetOrCreateConnectedClientAsync(accountId, cancellationToken); + var channel = await GetChannelByIdAsync(client, channelId) + ?? throw new InvalidOperationException($"Channel {channelId} not found"); + + if (fileStream == null) + throw new ArgumentException("头像文件为空", nameof(fileStream)); + + fileName = (fileName ?? "channel_photo.jpg").Trim(); + if (string.IsNullOrWhiteSpace(fileName)) + fileName = "channel_photo.jpg"; + + try + { + await using var encoded = await TelegramImageProcessor.PrepareAvatarJpegAsync(fileStream, cancellationToken); + var uploaded = await client.UploadFileAsync(encoded, fileName); + if (uploaded == null) + throw new InvalidOperationException("频道头像上传失败:上传结果为空"); + + await client.Channels_EditPhoto(channel, new InputChatUploadedPhoto + { + flags = InputChatUploadedPhoto.Flags.has_file, + file = uploaded + }); + + return true; + } + catch (SixLabors.ImageSharp.UnknownImageFormatException) + { + throw new InvalidOperationException("不支持的图片格式(建议使用 JPG/PNG)"); + } + } + public async Task KickUserAsync(int accountId, long channelId, string username, bool permanentBan = false) { var client = await GetOrCreateConnectedClientAsync(accountId); diff --git a/src/TelegramPanel.Core/Services/Telegram/GroupService.cs b/src/TelegramPanel.Core/Services/Telegram/GroupService.cs index dd2743a..9372efb 100644 --- a/src/TelegramPanel.Core/Services/Telegram/GroupService.cs +++ b/src/TelegramPanel.Core/Services/Telegram/GroupService.cs @@ -5,6 +5,7 @@ using TelegramPanel.Core.Interfaces; using TelegramPanel.Core.Models; using TelegramPanel.Core.Services; using TL; +using WTelegram; namespace TelegramPanel.Core.Services.Telegram; @@ -416,6 +417,121 @@ public class GroupService : IGroupService return groups.FirstOrDefault(g => g.TelegramId == groupId); } + public async Task UpdateGroupInfoAsync(int accountId, long groupId, string title, string? about) + { + var client = await GetOrCreateConnectedClientAsync(accountId); + var group = await GetEditableMegaGroupAsync(accountId, client, groupId, CancellationToken.None); + + title = (title ?? string.Empty).Trim(); + about = string.IsNullOrWhiteSpace(about) ? null : about.Trim(); + if (string.IsNullOrWhiteSpace(title)) + throw new ArgumentException("群组标题不能为空", nameof(title)); + + await client.Channels_EditTitle(group, title); + if (about != null) + await client.Messages_EditChatAbout(group, about); + + return true; + } + + public async Task SetGroupVisibilityAsync(int accountId, long groupId, bool isPublic, string? username = null) + { + var client = await GetOrCreateConnectedClientAsync(accountId); + var group = await GetEditableMegaGroupAsync(accountId, client, groupId, CancellationToken.None); + + if (isPublic) + { + username = (username ?? string.Empty).Trim().TrimStart('@'); + if (string.IsNullOrWhiteSpace(username)) + throw new InvalidOperationException("公开群组需要设置用户名"); + + var current = (group.MainUsername ?? string.Empty).Trim(); + if (string.Equals(current, username, StringComparison.OrdinalIgnoreCase)) + return true; + + var available = await client.Channels_CheckUsername(group, username); + if (!available) + throw new InvalidOperationException($"用户名 '{username}' 不可用"); + + try + { + await client.Channels_UpdateUsername(group, username); + } + catch (RpcException ex) when (ex.Message.Contains("USERNAME_NOT_MODIFIED", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + else + { + if (string.IsNullOrWhiteSpace(group.MainUsername)) + return true; + + await client.Channels_UpdateUsername(group, string.Empty); + } + + return true; + } + + public async Task SetGroupPhotoAsync( + int accountId, + long groupId, + Stream fileStream, + string fileName, + CancellationToken cancellationToken = default) + { + var client = await GetOrCreateConnectedClientAsync(accountId, cancellationToken); + var group = await GetEditableMegaGroupAsync(accountId, client, groupId, cancellationToken); + + if (fileStream == null) + throw new ArgumentException("头像文件为空", nameof(fileStream)); + + fileName = (fileName ?? "group_photo.jpg").Trim(); + if (string.IsNullOrWhiteSpace(fileName)) + fileName = "group_photo.jpg"; + + try + { + await using var encoded = await TelegramImageProcessor.PrepareAvatarJpegAsync(fileStream, cancellationToken); + var uploaded = await client.UploadFileAsync(encoded, fileName); + if (uploaded == null) + throw new InvalidOperationException("群组头像上传失败:上传结果为空"); + + await client.Channels_EditPhoto(group, new InputChatUploadedPhoto + { + flags = InputChatUploadedPhoto.Flags.has_file, + file = uploaded + }); + + return true; + } + catch (SixLabors.ImageSharp.UnknownImageFormatException) + { + throw new InvalidOperationException("不支持的图片格式(建议使用 JPG/PNG)"); + } + } + + private async Task GetEditableMegaGroupAsync(int accountId, Client client, long groupId, CancellationToken cancellationToken) + { + var dialogs = await ExecuteTelegramRequestAsync( + accountId, + "拉取频道/群组对话列表", + () => client.Messages_GetAllDialogs(), + cancellationToken, + resetClientOnTimeout: true); + + var normalizedId = groupId > 0 ? groupId : Math.Abs(groupId); + var mega = dialogs.chats.Values.OfType() + .FirstOrDefault(x => x.IsActive && !x.IsChannel && x.id == normalizedId); + if (mega != null) + return mega; + + if (dialogs.chats.Values.OfType().Any(x => x.IsActive && x.id == normalizedId)) + throw new InvalidOperationException("当前仅支持超级群组自动修改资料/公开状态"); + + throw new InvalidOperationException($"群组 {groupId} not found"); + } + public async Task LeaveGroupAsync(int accountId, long groupId) { try diff --git a/src/TelegramPanel.Core/Services/Telegram/TelegramImageProcessor.cs b/src/TelegramPanel.Core/Services/Telegram/TelegramImageProcessor.cs new file mode 100644 index 0000000..ad30c09 --- /dev/null +++ b/src/TelegramPanel.Core/Services/Telegram/TelegramImageProcessor.cs @@ -0,0 +1,73 @@ +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Formats.Jpeg; +using SixLabors.ImageSharp.Processing; + +namespace TelegramPanel.Core.Services.Telegram; + +/// +/// Telegram 图片处理工具。 +/// +public static class TelegramImageProcessor +{ + public static async Task PrepareAvatarJpegAsync(Stream fileStream, CancellationToken cancellationToken = default) + { + if (fileStream == null) + throw new ArgumentNullException(nameof(fileStream)); + + await using var raw = new MemoryStream(); + if (fileStream.CanSeek) + fileStream.Position = 0; + + await fileStream.CopyToAsync(raw, cancellationToken); + raw.Position = 0; + + using var image = await Image.LoadAsync(raw, cancellationToken); + image.Mutate(x => x.AutoOrient()); + image.Mutate(x => x.Resize(new ResizeOptions + { + Mode = ResizeMode.Crop, + Size = new Size(512, 512) + })); + + var encoded = new MemoryStream(); + await image.SaveAsJpegAsync(encoded, new JpegEncoder { Quality = 85 }, cancellationToken); + encoded.Position = 0; + return encoded; + } + + public static async Task PrepareStoredImageJpegAsync( + Stream fileStream, + int maxDimension = 2048, + CancellationToken cancellationToken = default) + { + if (fileStream == null) + throw new ArgumentNullException(nameof(fileStream)); + + if (maxDimension < 256) + maxDimension = 256; + + await using var raw = new MemoryStream(); + if (fileStream.CanSeek) + fileStream.Position = 0; + + await fileStream.CopyToAsync(raw, cancellationToken); + raw.Position = 0; + + using var image = await Image.LoadAsync(raw, cancellationToken); + image.Mutate(x => x.AutoOrient()); + + if (image.Width > maxDimension || image.Height > maxDimension) + { + image.Mutate(x => x.Resize(new ResizeOptions + { + Mode = ResizeMode.Max, + Size = new Size(maxDimension, maxDimension) + })); + } + + var encoded = new MemoryStream(); + await image.SaveAsJpegAsync(encoded, new JpegEncoder { Quality = 88 }, cancellationToken); + encoded.Position = 0; + return encoded; + } +} diff --git a/src/TelegramPanel.Data/AppDbContext.cs b/src/TelegramPanel.Data/AppDbContext.cs index 0a3ab3b..de95058 100644 --- a/src/TelegramPanel.Data/AppDbContext.cs +++ b/src/TelegramPanel.Data/AppDbContext.cs @@ -22,6 +22,9 @@ public class AppDbContext : DbContext public DbSet Groups => Set(); public DbSet GroupCategories => Set(); public DbSet BatchTasks => Set(); + public DbSet ScheduledTasks => Set(); + public DbSet DataDictionaries => Set(); + public DbSet DataDictionaryItems => Set(); public DbSet Bots => Set(); public DbSet BotChannels => Set(); public DbSet BotChannelCategories => Set(); @@ -72,6 +75,7 @@ public class AppDbContext : DbContext entity.Property(e => e.Title).IsRequired().HasMaxLength(200); entity.Property(e => e.Username).HasMaxLength(100); entity.Property(e => e.About).HasMaxLength(1000); + entity.Property(e => e.SystemCreatedAtUtc); entity.HasIndex(e => e.TelegramId).IsUnique(); entity.HasIndex(e => e.Username); @@ -126,6 +130,7 @@ public class AppDbContext : DbContext entity.Property(e => e.Title).IsRequired().HasMaxLength(200); entity.Property(e => e.Username).HasMaxLength(100); entity.Property(e => e.About).HasMaxLength(1000); + entity.Property(e => e.SystemCreatedAtUtc); entity.HasIndex(e => e.TelegramId).IsUnique(); entity.HasIndex(e => e.Username); @@ -184,6 +189,52 @@ public class AppDbContext : DbContext entity.HasIndex(e => e.CreatedAt); }); + // ScheduledTask配置 + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.Property(e => e.TaskType).IsRequired().HasMaxLength(100); + entity.Property(e => e.Status).IsRequired().HasMaxLength(20); + entity.Property(e => e.CronExpression).IsRequired().HasMaxLength(100); + entity.Property(e => e.OwnedAssetScopeId).HasMaxLength(120); + + entity.HasIndex(e => e.Status); + entity.HasIndex(e => e.NextRunAtUtc); + entity.HasIndex(e => e.CreatedAt); + }); + + // DataDictionary配置 + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.Property(e => e.Name).IsRequired().HasMaxLength(100); + entity.Property(e => e.DisplayName).IsRequired().HasMaxLength(100); + entity.Property(e => e.Description).HasMaxLength(500); + entity.Property(e => e.Type).IsRequired().HasMaxLength(20); + entity.Property(e => e.ReadMode).IsRequired().HasMaxLength(20); + + entity.HasIndex(e => e.Name).IsUnique(); + entity.HasIndex(e => e.Type); + entity.HasIndex(e => e.IsEnabled); + }); + + // DataDictionaryItem配置 + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.Property(e => e.TextValue).HasMaxLength(4000); + entity.Property(e => e.AssetPath).HasMaxLength(500); + entity.Property(e => e.FileName).HasMaxLength(255); + + entity.HasIndex(e => e.DataDictionaryId); + entity.HasIndex(e => new { e.DataDictionaryId, e.SortOrder }); + + entity.HasOne(e => e.Dictionary) + .WithMany(d => d.Items) + .HasForeignKey(e => e.DataDictionaryId) + .OnDelete(DeleteBehavior.Cascade); + }); + // Bot配置 modelBuilder.Entity(entity => { diff --git a/src/TelegramPanel.Data/Entities/Channel.cs b/src/TelegramPanel.Data/Entities/Channel.cs index d76deda..88b9439 100644 --- a/src/TelegramPanel.Data/Entities/Channel.cs +++ b/src/TelegramPanel.Data/Entities/Channel.cs @@ -16,6 +16,7 @@ public class Channel public int? CreatorAccountId { get; set; } public int? GroupId { get; set; } public DateTime? CreatedAt { get; set; } + public DateTime? SystemCreatedAtUtc { get; set; } public DateTime SyncedAt { get; set; } = DateTime.UtcNow; // 导航属性 diff --git a/src/TelegramPanel.Data/Entities/DataDictionary.cs b/src/TelegramPanel.Data/Entities/DataDictionary.cs new file mode 100644 index 0000000..3daa57e --- /dev/null +++ b/src/TelegramPanel.Data/Entities/DataDictionary.cs @@ -0,0 +1,38 @@ +namespace TelegramPanel.Data.Entities; + +/// +/// 数据字典类型常量。 +/// +public static class DataDictionaryTypes +{ + public const string Text = "text"; + public const string Image = "image"; +} + +/// +/// 数据字典读取模式常量。 +/// +public static class DataDictionaryReadModes +{ + public const string Random = "random"; + public const string Queue = "queue"; +} + +/// +/// 数据字典。 +/// +public class DataDictionary +{ + public int Id { get; set; } + public string Name { get; set; } = null!; + public string DisplayName { get; set; } = null!; + public string? Description { get; set; } + public string Type { get; set; } = DataDictionaryTypes.Text; + public string ReadMode { get; set; } = DataDictionaryReadModes.Random; + public bool IsEnabled { get; set; } = true; + public int NextIndex { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + + public ICollection Items { get; set; } = new List(); +} diff --git a/src/TelegramPanel.Data/Entities/DataDictionaryItem.cs b/src/TelegramPanel.Data/Entities/DataDictionaryItem.cs new file mode 100644 index 0000000..9ab6045 --- /dev/null +++ b/src/TelegramPanel.Data/Entities/DataDictionaryItem.cs @@ -0,0 +1,18 @@ +namespace TelegramPanel.Data.Entities; + +/// +/// 数据字典项。 +/// +public class DataDictionaryItem +{ + public int Id { get; set; } + public int DataDictionaryId { get; set; } + public string? TextValue { get; set; } + public string? AssetPath { get; set; } + public string? FileName { get; set; } + public int SortOrder { get; set; } + public bool IsEnabled { get; set; } = true; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + public DataDictionary Dictionary { get; set; } = null!; +} diff --git a/src/TelegramPanel.Data/Entities/Group.cs b/src/TelegramPanel.Data/Entities/Group.cs index e34eb8e..a78c5f1 100644 --- a/src/TelegramPanel.Data/Entities/Group.cs +++ b/src/TelegramPanel.Data/Entities/Group.cs @@ -15,6 +15,7 @@ public class Group public int? CreatorAccountId { get; set; } public int? CategoryId { get; set; } public DateTime? CreatedAt { get; set; } + public DateTime? SystemCreatedAtUtc { get; set; } public DateTime SyncedAt { get; set; } = DateTime.UtcNow; // 导航属性 diff --git a/src/TelegramPanel.Data/Entities/ScheduledTask.cs b/src/TelegramPanel.Data/Entities/ScheduledTask.cs new file mode 100644 index 0000000..f344122 --- /dev/null +++ b/src/TelegramPanel.Data/Entities/ScheduledTask.cs @@ -0,0 +1,29 @@ +namespace TelegramPanel.Data.Entities; + +/// +/// 计划任务状态常量。 +/// +public static class ScheduledTaskStatuses +{ + public const string Enabled = "enabled"; + public const string Paused = "paused"; +} + +/// +/// Cron 计划任务定义。 +/// +public class ScheduledTask +{ + public int Id { get; set; } + public string TaskType { get; set; } = null!; + public string Status { get; set; } = ScheduledTaskStatuses.Enabled; + public int Total { get; set; } + public string? ConfigJson { get; set; } + public string CronExpression { get; set; } = null!; + public DateTime? NextRunAtUtc { get; set; } + public DateTime? LastRunAtUtc { get; set; } + public int? LastBatchTaskId { get; set; } + public string? OwnedAssetScopeId { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/src/TelegramPanel.Data/Migrations/20260309132915_AddScheduledTasksAndDataDictionaries.Designer.cs b/src/TelegramPanel.Data/Migrations/20260309132915_AddScheduledTasksAndDataDictionaries.Designer.cs new file mode 100644 index 0000000..32d0188 --- /dev/null +++ b/src/TelegramPanel.Data/Migrations/20260309132915_AddScheduledTasksAndDataDictionaries.Designer.cs @@ -0,0 +1,917 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using TelegramPanel.Data; + +#nullable disable + +namespace TelegramPanel.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260309132915_AddScheduledTasksAndDataDictionaries")] + partial class AddScheduledTasksAndDataDictionaries + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.24"); + + modelBuilder.Entity("TelegramPanel.Data.Entities.Account", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApiHash") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("ApiId") + .HasColumnType("INTEGER"); + + b.Property("CategoryId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EstimatedRegistrationAt") + .HasColumnType("TEXT"); + + b.Property("EstimatedRegistrationCheckedAtUtc") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("LastLoginAt") + .HasColumnType("TEXT"); + + b.Property("LastSyncAt") + .HasColumnType("TEXT"); + + b.Property("Nickname") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Phone") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("SessionPath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("TelegramStatusCheckedAtUtc") + .HasColumnType("TEXT"); + + b.Property("TelegramStatusDetails") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("TelegramStatusOk") + .HasColumnType("INTEGER"); + + b.Property("TelegramStatusSummary") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("TwoFactorPassword") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("Username") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId"); + + b.HasIndex("Phone") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("Accounts"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.AccountCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Color") + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ExcludeFromOperations") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("AccountCategories"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.AccountChannel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccountId") + .HasColumnType("INTEGER"); + + b.Property("ChannelId") + .HasColumnType("INTEGER"); + + b.Property("IsAdmin") + .HasColumnType("INTEGER"); + + b.Property("IsCreator") + .HasColumnType("INTEGER"); + + b.Property("SyncedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId"); + + b.HasIndex("AccountId", "ChannelId") + .IsUnique(); + + b.HasIndex("ChannelId", "IsCreator", "IsAdmin"); + + b.ToTable("AccountChannels"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.AccountGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccountId") + .HasColumnType("INTEGER"); + + b.Property("GroupId") + .HasColumnType("INTEGER"); + + b.Property("IsAdmin") + .HasColumnType("INTEGER"); + + b.Property("IsCreator") + .HasColumnType("INTEGER"); + + b.Property("SyncedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.HasIndex("AccountId", "GroupId") + .IsUnique(); + + b.HasIndex("GroupId", "IsCreator", "IsAdmin"); + + b.ToTable("AccountGroups"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.BatchTask", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Completed") + .HasColumnType("INTEGER"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("Config") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Failed") + .HasColumnType("INTEGER"); + + b.Property("StartedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("TaskType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Total") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("Status"); + + b.ToTable("BatchTasks"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.Bot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("LastSyncAt") + .HasColumnType("TEXT"); + + b.Property("LastUpdateId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Username") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("Username"); + + b.ToTable("Bots"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("About") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("AccessHash") + .HasColumnType("INTEGER"); + + b.Property("CategoryId") + .HasColumnType("INTEGER"); + + b.Property("ChannelStatusCheckedAtUtc") + .HasColumnType("TEXT"); + + b.Property("ChannelStatusError") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ChannelStatusOk") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("IsBroadcast") + .HasColumnType("INTEGER"); + + b.Property("MemberCount") + .HasColumnType("INTEGER"); + + b.Property("SyncedAt") + .HasColumnType("TEXT"); + + b.Property("TelegramId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Username") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId"); + + b.HasIndex("ChannelStatusOk"); + + b.HasIndex("TelegramId") + .IsUnique(); + + b.HasIndex("Username"); + + b.ToTable("BotChannels"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannelCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("BotChannelCategories"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannelMember", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BotChannelId") + .HasColumnType("INTEGER"); + + b.Property("BotId") + .HasColumnType("INTEGER"); + + b.Property("SyncedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("BotChannelId"); + + b.HasIndex("BotId"); + + b.HasIndex("BotId", "BotChannelId") + .IsUnique(); + + b.ToTable("BotChannelMembers"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.Channel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("About") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("AccessHash") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorAccountId") + .HasColumnType("INTEGER"); + + b.Property("GroupId") + .HasColumnType("INTEGER"); + + b.Property("IsBroadcast") + .HasColumnType("INTEGER"); + + b.Property("MemberCount") + .HasColumnType("INTEGER"); + + b.Property("SyncedAt") + .HasColumnType("TEXT"); + + b.Property("SystemCreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("TelegramId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Username") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatorAccountId"); + + b.HasIndex("GroupId"); + + b.HasIndex("SyncedAt"); + + b.HasIndex("TelegramId") + .IsUnique(); + + b.HasIndex("Username"); + + b.ToTable("Channels"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.ChannelGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ChannelGroups"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.DataDictionary", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("NextIndex") + .HasColumnType("INTEGER"); + + b.Property("ReadMode") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("IsEnabled"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("Type"); + + b.ToTable("DataDictionaries"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.DataDictionaryItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AssetPath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DataDictionaryId") + .HasColumnType("INTEGER"); + + b.Property("FileName") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("TextValue") + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DataDictionaryId"); + + b.HasIndex("DataDictionaryId", "SortOrder"); + + b.ToTable("DataDictionaryItems"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("About") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("AccessHash") + .HasColumnType("INTEGER"); + + b.Property("CategoryId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorAccountId") + .HasColumnType("INTEGER"); + + b.Property("MemberCount") + .HasColumnType("INTEGER"); + + b.Property("SyncedAt") + .HasColumnType("TEXT"); + + b.Property("SystemCreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("TelegramId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Username") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId"); + + b.HasIndex("CreatorAccountId"); + + b.HasIndex("SyncedAt"); + + b.HasIndex("TelegramId") + .IsUnique(); + + b.HasIndex("Username"); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.GroupCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("GroupCategories"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.ScheduledTask", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConfigJson") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CronExpression") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LastBatchTaskId") + .HasColumnType("INTEGER"); + + b.Property("LastRunAtUtc") + .HasColumnType("TEXT"); + + b.Property("NextRunAtUtc") + .HasColumnType("TEXT"); + + b.Property("OwnedAssetScopeId") + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("TaskType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Total") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("NextRunAtUtc"); + + b.HasIndex("Status"); + + b.ToTable("ScheduledTasks"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.Account", b => + { + b.HasOne("TelegramPanel.Data.Entities.AccountCategory", "Category") + .WithMany("Accounts") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Category"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.AccountChannel", b => + { + b.HasOne("TelegramPanel.Data.Entities.Account", "Account") + .WithMany("AccountChannels") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TelegramPanel.Data.Entities.Channel", "Channel") + .WithMany("AccountChannels") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Account"); + + b.Navigation("Channel"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.AccountGroup", b => + { + b.HasOne("TelegramPanel.Data.Entities.Account", "Account") + .WithMany("AccountGroups") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TelegramPanel.Data.Entities.Group", "Group") + .WithMany("AccountGroups") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Account"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannel", b => + { + b.HasOne("TelegramPanel.Data.Entities.BotChannelCategory", "Category") + .WithMany("Channels") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Category"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannelMember", b => + { + b.HasOne("TelegramPanel.Data.Entities.BotChannel", "BotChannel") + .WithMany("Members") + .HasForeignKey("BotChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TelegramPanel.Data.Entities.Bot", "Bot") + .WithMany("ChannelMembers") + .HasForeignKey("BotId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Bot"); + + b.Navigation("BotChannel"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.Channel", b => + { + b.HasOne("TelegramPanel.Data.Entities.Account", "CreatorAccount") + .WithMany("Channels") + .HasForeignKey("CreatorAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("TelegramPanel.Data.Entities.ChannelGroup", "Group") + .WithMany("Channels") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("CreatorAccount"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.DataDictionaryItem", b => + { + b.HasOne("TelegramPanel.Data.Entities.DataDictionary", "Dictionary") + .WithMany("Items") + .HasForeignKey("DataDictionaryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Dictionary"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.Group", b => + { + b.HasOne("TelegramPanel.Data.Entities.GroupCategory", "Category") + .WithMany("Groups") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("TelegramPanel.Data.Entities.Account", "CreatorAccount") + .WithMany("Groups") + .HasForeignKey("CreatorAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Category"); + + b.Navigation("CreatorAccount"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.Account", b => + { + b.Navigation("AccountChannels"); + + b.Navigation("AccountGroups"); + + b.Navigation("Channels"); + + b.Navigation("Groups"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.AccountCategory", b => + { + b.Navigation("Accounts"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.Bot", b => + { + b.Navigation("ChannelMembers"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannel", b => + { + b.Navigation("Members"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannelCategory", b => + { + b.Navigation("Channels"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.Channel", b => + { + b.Navigation("AccountChannels"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.ChannelGroup", b => + { + b.Navigation("Channels"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.DataDictionary", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.Group", b => + { + b.Navigation("AccountGroups"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.GroupCategory", b => + { + b.Navigation("Groups"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/TelegramPanel.Data/Migrations/20260309132915_AddScheduledTasksAndDataDictionaries.cs b/src/TelegramPanel.Data/Migrations/20260309132915_AddScheduledTasksAndDataDictionaries.cs new file mode 100644 index 0000000..91c5481 --- /dev/null +++ b/src/TelegramPanel.Data/Migrations/20260309132915_AddScheduledTasksAndDataDictionaries.cs @@ -0,0 +1,158 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TelegramPanel.Data.Migrations +{ + /// + public partial class AddScheduledTasksAndDataDictionaries : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "SystemCreatedAtUtc", + table: "Groups", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "SystemCreatedAtUtc", + table: "Channels", + type: "TEXT", + nullable: true); + + migrationBuilder.CreateTable( + name: "DataDictionaries", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Name = table.Column(type: "TEXT", maxLength: 100, nullable: false), + DisplayName = table.Column(type: "TEXT", maxLength: 100, nullable: false), + Description = table.Column(type: "TEXT", maxLength: 500, nullable: true), + Type = table.Column(type: "TEXT", maxLength: 20, nullable: false), + ReadMode = table.Column(type: "TEXT", maxLength: 20, nullable: false), + IsEnabled = table.Column(type: "INTEGER", nullable: false), + NextIndex = table.Column(type: "INTEGER", nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_DataDictionaries", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "ScheduledTasks", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + TaskType = table.Column(type: "TEXT", maxLength: 100, nullable: false), + Status = table.Column(type: "TEXT", maxLength: 20, nullable: false), + Total = table.Column(type: "INTEGER", nullable: false), + ConfigJson = table.Column(type: "TEXT", nullable: true), + CronExpression = table.Column(type: "TEXT", maxLength: 100, nullable: false), + NextRunAtUtc = table.Column(type: "TEXT", nullable: true), + LastRunAtUtc = table.Column(type: "TEXT", nullable: true), + LastBatchTaskId = table.Column(type: "INTEGER", nullable: true), + OwnedAssetScopeId = table.Column(type: "TEXT", maxLength: 120, nullable: true), + CreatedAt = table.Column(type: "TEXT", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ScheduledTasks", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "DataDictionaryItems", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + DataDictionaryId = table.Column(type: "INTEGER", nullable: false), + TextValue = table.Column(type: "TEXT", maxLength: 4000, nullable: true), + AssetPath = table.Column(type: "TEXT", maxLength: 500, nullable: true), + FileName = table.Column(type: "TEXT", maxLength: 255, nullable: true), + SortOrder = table.Column(type: "INTEGER", nullable: false), + IsEnabled = table.Column(type: "INTEGER", nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_DataDictionaryItems", x => x.Id); + table.ForeignKey( + name: "FK_DataDictionaryItems_DataDictionaries_DataDictionaryId", + column: x => x.DataDictionaryId, + principalTable: "DataDictionaries", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_DataDictionaries_IsEnabled", + table: "DataDictionaries", + column: "IsEnabled"); + + migrationBuilder.CreateIndex( + name: "IX_DataDictionaries_Name", + table: "DataDictionaries", + column: "Name", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_DataDictionaries_Type", + table: "DataDictionaries", + column: "Type"); + + migrationBuilder.CreateIndex( + name: "IX_DataDictionaryItems_DataDictionaryId", + table: "DataDictionaryItems", + column: "DataDictionaryId"); + + migrationBuilder.CreateIndex( + name: "IX_DataDictionaryItems_DataDictionaryId_SortOrder", + table: "DataDictionaryItems", + columns: new[] { "DataDictionaryId", "SortOrder" }); + + migrationBuilder.CreateIndex( + name: "IX_ScheduledTasks_CreatedAt", + table: "ScheduledTasks", + column: "CreatedAt"); + + migrationBuilder.CreateIndex( + name: "IX_ScheduledTasks_NextRunAtUtc", + table: "ScheduledTasks", + column: "NextRunAtUtc"); + + migrationBuilder.CreateIndex( + name: "IX_ScheduledTasks_Status", + table: "ScheduledTasks", + column: "Status"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "DataDictionaryItems"); + + migrationBuilder.DropTable( + name: "ScheduledTasks"); + + migrationBuilder.DropTable( + name: "DataDictionaries"); + + migrationBuilder.DropColumn( + name: "SystemCreatedAtUtc", + table: "Groups"); + + migrationBuilder.DropColumn( + name: "SystemCreatedAtUtc", + table: "Channels"); + } + } +} diff --git a/src/TelegramPanel.Data/Migrations/AppDbContextModelSnapshot.cs b/src/TelegramPanel.Data/Migrations/AppDbContextModelSnapshot.cs index 826e1c4..36e4691 100644 --- a/src/TelegramPanel.Data/Migrations/AppDbContextModelSnapshot.cs +++ b/src/TelegramPanel.Data/Migrations/AppDbContextModelSnapshot.cs @@ -439,6 +439,9 @@ namespace TelegramPanel.Data.Migrations b.Property("SyncedAt") .HasColumnType("TEXT"); + b.Property("SystemCreatedAtUtc") + .HasColumnType("TEXT"); + b.Property("TelegramId") .HasColumnType("INTEGER"); @@ -493,6 +496,99 @@ namespace TelegramPanel.Data.Migrations b.ToTable("ChannelGroups"); }); + modelBuilder.Entity("TelegramPanel.Data.Entities.DataDictionary", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("NextIndex") + .HasColumnType("INTEGER"); + + b.Property("ReadMode") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("IsEnabled"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("Type"); + + b.ToTable("DataDictionaries"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.DataDictionaryItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AssetPath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DataDictionaryId") + .HasColumnType("INTEGER"); + + b.Property("FileName") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("TextValue") + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DataDictionaryId"); + + b.HasIndex("DataDictionaryId", "SortOrder"); + + b.ToTable("DataDictionaryItems"); + }); + modelBuilder.Entity("TelegramPanel.Data.Entities.Group", b => { b.Property("Id") @@ -521,6 +617,9 @@ namespace TelegramPanel.Data.Migrations b.Property("SyncedAt") .HasColumnType("TEXT"); + b.Property("SystemCreatedAtUtc") + .HasColumnType("TEXT"); + b.Property("TelegramId") .HasColumnType("INTEGER"); @@ -575,6 +674,63 @@ namespace TelegramPanel.Data.Migrations b.ToTable("GroupCategories"); }); + modelBuilder.Entity("TelegramPanel.Data.Entities.ScheduledTask", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConfigJson") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CronExpression") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LastBatchTaskId") + .HasColumnType("INTEGER"); + + b.Property("LastRunAtUtc") + .HasColumnType("TEXT"); + + b.Property("NextRunAtUtc") + .HasColumnType("TEXT"); + + b.Property("OwnedAssetScopeId") + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("TaskType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Total") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("NextRunAtUtc"); + + b.HasIndex("Status"); + + b.ToTable("ScheduledTasks"); + }); + modelBuilder.Entity("TelegramPanel.Data.Entities.Account", b => { b.HasOne("TelegramPanel.Data.Entities.AccountCategory", "Category") @@ -669,6 +825,17 @@ namespace TelegramPanel.Data.Migrations b.Navigation("Group"); }); + modelBuilder.Entity("TelegramPanel.Data.Entities.DataDictionaryItem", b => + { + b.HasOne("TelegramPanel.Data.Entities.DataDictionary", "Dictionary") + .WithMany("Items") + .HasForeignKey("DataDictionaryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Dictionary"); + }); + modelBuilder.Entity("TelegramPanel.Data.Entities.Group", b => { b.HasOne("TelegramPanel.Data.Entities.GroupCategory", "Category") @@ -727,6 +894,11 @@ namespace TelegramPanel.Data.Migrations b.Navigation("Channels"); }); + modelBuilder.Entity("TelegramPanel.Data.Entities.DataDictionary", b => + { + b.Navigation("Items"); + }); + modelBuilder.Entity("TelegramPanel.Data.Entities.Group", b => { b.Navigation("AccountGroups"); diff --git a/src/TelegramPanel.Data/Repositories/DataDictionaryItemRepository.cs b/src/TelegramPanel.Data/Repositories/DataDictionaryItemRepository.cs new file mode 100644 index 0000000..afb8774 --- /dev/null +++ b/src/TelegramPanel.Data/Repositories/DataDictionaryItemRepository.cs @@ -0,0 +1,36 @@ +using Microsoft.EntityFrameworkCore; +using TelegramPanel.Data.Entities; + +namespace TelegramPanel.Data.Repositories; + +/// +/// 数据字典项仓储实现。 +/// +public sealed class DataDictionaryItemRepository : Repository, IDataDictionaryItemRepository +{ + public DataDictionaryItemRepository(AppDbContext context) : base(context) + { + } + + public async Task> GetByDictionaryIdAsync(int dictionaryId, CancellationToken cancellationToken = default) + { + return await _dbSet + .Where(x => x.DataDictionaryId == dictionaryId) + .OrderBy(x => x.SortOrder) + .ThenBy(x => x.Id) + .ToListAsync(cancellationToken); + } + + public async Task DeleteByDictionaryIdAsync(int dictionaryId, CancellationToken cancellationToken = default) + { + var items = await _dbSet + .Where(x => x.DataDictionaryId == dictionaryId) + .ToListAsync(cancellationToken); + + if (items.Count == 0) + return; + + _dbSet.RemoveRange(items); + await SaveChangesWithSqliteLockRetryAsync(cancellationToken); + } +} diff --git a/src/TelegramPanel.Data/Repositories/DataDictionaryRepository.cs b/src/TelegramPanel.Data/Repositories/DataDictionaryRepository.cs new file mode 100644 index 0000000..df8a1b0 --- /dev/null +++ b/src/TelegramPanel.Data/Repositories/DataDictionaryRepository.cs @@ -0,0 +1,54 @@ +using Microsoft.EntityFrameworkCore; +using TelegramPanel.Data.Entities; + +namespace TelegramPanel.Data.Repositories; + +/// +/// 数据字典仓储实现。 +/// +public sealed class DataDictionaryRepository : Repository, IDataDictionaryRepository +{ + public DataDictionaryRepository(AppDbContext context) : base(context) + { + } + + public override async Task GetByIdAsync(int id) + { + return await GetWithItemsAsync(id); + } + + public override async Task> GetAllAsync() + { + return await GetAllWithItemsAsync(); + } + + public async Task GetByNameAsync(string name, CancellationToken cancellationToken = default) + { + name = (name ?? string.Empty).Trim(); + if (name.Length == 0) + return null; + + return await _dbSet + .Include(x => x.Items.OrderBy(i => i.SortOrder).ThenBy(i => i.Id)) + .AsSplitQuery() + .FirstOrDefaultAsync(x => x.Name == name, cancellationToken); + } + + public async Task GetWithItemsAsync(int id, CancellationToken cancellationToken = default) + { + return await _dbSet + .Include(x => x.Items.OrderBy(i => i.SortOrder).ThenBy(i => i.Id)) + .AsSplitQuery() + .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + } + + public async Task> GetAllWithItemsAsync(CancellationToken cancellationToken = default) + { + return await _dbSet + .Include(x => x.Items.OrderBy(i => i.SortOrder).ThenBy(i => i.Id)) + .AsSplitQuery() + .OrderByDescending(x => x.CreatedAt) + .ThenByDescending(x => x.Id) + .ToListAsync(cancellationToken); + } +} diff --git a/src/TelegramPanel.Data/Repositories/IDataDictionaryItemRepository.cs b/src/TelegramPanel.Data/Repositories/IDataDictionaryItemRepository.cs new file mode 100644 index 0000000..b6a62d5 --- /dev/null +++ b/src/TelegramPanel.Data/Repositories/IDataDictionaryItemRepository.cs @@ -0,0 +1,12 @@ +using TelegramPanel.Data.Entities; + +namespace TelegramPanel.Data.Repositories; + +/// +/// 数据字典项仓储接口。 +/// +public interface IDataDictionaryItemRepository : IRepository +{ + Task> GetByDictionaryIdAsync(int dictionaryId, CancellationToken cancellationToken = default); + Task DeleteByDictionaryIdAsync(int dictionaryId, CancellationToken cancellationToken = default); +} diff --git a/src/TelegramPanel.Data/Repositories/IDataDictionaryRepository.cs b/src/TelegramPanel.Data/Repositories/IDataDictionaryRepository.cs new file mode 100644 index 0000000..c4da936 --- /dev/null +++ b/src/TelegramPanel.Data/Repositories/IDataDictionaryRepository.cs @@ -0,0 +1,13 @@ +using TelegramPanel.Data.Entities; + +namespace TelegramPanel.Data.Repositories; + +/// +/// 数据字典仓储接口。 +/// +public interface IDataDictionaryRepository : IRepository +{ + Task GetByNameAsync(string name, CancellationToken cancellationToken = default); + Task GetWithItemsAsync(int id, CancellationToken cancellationToken = default); + Task> GetAllWithItemsAsync(CancellationToken cancellationToken = default); +} diff --git a/src/TelegramPanel.Data/Repositories/IScheduledTaskRepository.cs b/src/TelegramPanel.Data/Repositories/IScheduledTaskRepository.cs new file mode 100644 index 0000000..5216136 --- /dev/null +++ b/src/TelegramPanel.Data/Repositories/IScheduledTaskRepository.cs @@ -0,0 +1,12 @@ +using TelegramPanel.Data.Entities; + +namespace TelegramPanel.Data.Repositories; + +/// +/// 计划任务仓储接口。 +/// +public interface IScheduledTaskRepository : IRepository +{ + Task> GetAllOrderedAsync(CancellationToken cancellationToken = default); + Task> GetEnabledAsync(CancellationToken cancellationToken = default); +} diff --git a/src/TelegramPanel.Data/Repositories/ScheduledTaskRepository.cs b/src/TelegramPanel.Data/Repositories/ScheduledTaskRepository.cs new file mode 100644 index 0000000..7e083bd --- /dev/null +++ b/src/TelegramPanel.Data/Repositories/ScheduledTaskRepository.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore; +using TelegramPanel.Data.Entities; + +namespace TelegramPanel.Data.Repositories; + +/// +/// 计划任务仓储实现。 +/// +public sealed class ScheduledTaskRepository : Repository, IScheduledTaskRepository +{ + public ScheduledTaskRepository(AppDbContext context) : base(context) + { + } + + public async Task> GetAllOrderedAsync(CancellationToken cancellationToken = default) + { + return await _dbSet + .OrderByDescending(x => x.CreatedAt) + .ThenByDescending(x => x.Id) + .ToListAsync(cancellationToken); + } + + public async Task> GetEnabledAsync(CancellationToken cancellationToken = default) + { + return await _dbSet + .Where(x => x.Status == ScheduledTaskStatuses.Enabled) + .OrderBy(x => x.NextRunAtUtc ?? DateTime.MaxValue) + .ThenBy(x => x.Id) + .ToListAsync(cancellationToken); + } +} diff --git a/src/TelegramPanel.Data/ServiceCollectionExtensions.cs b/src/TelegramPanel.Data/ServiceCollectionExtensions.cs index 74c112e..41a8356 100644 --- a/src/TelegramPanel.Data/ServiceCollectionExtensions.cs +++ b/src/TelegramPanel.Data/ServiceCollectionExtensions.cs @@ -28,6 +28,9 @@ public static class ServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/TelegramPanel.Modules.Abstractions/ModuleContributions.cs b/src/TelegramPanel.Modules.Abstractions/ModuleContributions.cs index bc0c9f7..0ef4331 100644 --- a/src/TelegramPanel.Modules.Abstractions/ModuleContributions.cs +++ b/src/TelegramPanel.Modules.Abstractions/ModuleContributions.cs @@ -1,4 +1,4 @@ -using System.Text.Json.Serialization; +using System.Text.Json.Serialization; namespace TelegramPanel.Modules; @@ -132,7 +132,17 @@ public sealed class ModuleTaskCenterCapabilities public bool AutoPauseBeforeEdit { get; set; } } -public readonly record struct ModuleTaskDraft(int Total, string? Config, bool CanSubmit, string? ValidationError); +public readonly record struct ModuleTaskDraft(int Total, string? Config, bool CanSubmit, string? ValidationError) +{ + [JsonPropertyName("submissionMode")] + public string? SubmissionMode { get; init; } + + [JsonPropertyName("cronExpression")] + public string? CronExpression { get; init; } + + [JsonPropertyName("scheduledStatus")] + public string? ScheduledStatus { get; init; } +} public sealed class ModuleTaskSnapshot { @@ -217,3 +227,5 @@ public interface IModuleTaskExecutionHost Task IsStillRunningAsync(CancellationToken cancellationToken); Task UpdateProgressAsync(int completed, int failed, CancellationToken cancellationToken); } + + diff --git a/src/TelegramPanel.Web/Components/Dialogs/ChannelGroupPrivateCreateTaskEditor.razor b/src/TelegramPanel.Web/Components/Dialogs/ChannelGroupPrivateCreateTaskEditor.razor new file mode 100644 index 0000000..770bb6b --- /dev/null +++ b/src/TelegramPanel.Web/Components/Dialogs/ChannelGroupPrivateCreateTaskEditor.razor @@ -0,0 +1,344 @@ +@inject AccountCategoryManagementService CategoryManagement +@inject AccountManagementService AccountManagement +@inject ChannelGroupManagementService ChannelGroupManagement +@inject GroupCategoryManagementService GroupCategoryManagement +@inject DataDictionaryService DictionaryService +@inject ImageAssetStorageService AssetStorage +@using System.Text.Json +@using TelegramPanel.Data.Entities +@using TelegramPanel.Modules +@using TelegramPanel.Web.Services +@using Microsoft.AspNetCore.Components +@using Microsoft.AspNetCore.Components.Forms + + + @if (loading) + { + + 正在加载配置项... + } + else + { + + @foreach (var item in categories) + { + @item.Name + } + + + + 频道 + 群组 + + + @if (IsChannel) + { + + 未分组 + @foreach (var item in channelGroups) + { + @item.Name + } + + } + else + { + + 未分类 + @foreach (var item in groupCategories) + { + @item.Name + } + + } + + + + + + + + + + + + + @if (textDictionaryNames.Count > 0) + { + 可用文本变量:@string.Join("、", textDictionaryNames.Select(x => $"{{{x}}}")) + } + + + 不设置 + 固定上传 + 图片字典变量 + + + @if (avatarSource == ChannelGroupAutomationAvatarSourceModes.Fixed) + { + + + 上传固定头像 + + + @if (!string.IsNullOrWhiteSpace(fixedAvatarAssetPath)) + { + 已保存头像:@fixedAvatarAssetPath + } + } + else if (avatarSource == ChannelGroupAutomationAvatarSourceModes.Dictionary) + { + + -- 请选择图片字典 -- + @foreach (var item in imageDictionaryNames) + { + @item + } + + } + + + + + + + + + + + + + } + + +@code { + [Parameter] public ModuleTaskDraft Draft { get; set; } + [Parameter] public EventCallback DraftChanged { get; set; } + [Parameter] public string? InitialConfigJson { get; set; } + + private bool loading = true; + private bool initialized; + private List categories = new(); + private List activeAccounts = new(); + private List channelGroups = new(); + private List groupCategories = new(); + private List textDictionaryNames = new(); + private List imageDictionaryNames = new(); + + private IEnumerable selectedCategoryIds = Array.Empty(); + private string createType = ChannelGroupAutomationTaskObjectTypes.Channel; + private int selectedChannelGroupId; + private int selectedGroupCategoryId; + private int systemCreatedLimit = 10; + private int perAccountBatchSize = 1; + private string titleTemplate = string.Empty; + private string avatarSource = ChannelGroupAutomationAvatarSourceModes.None; + private string? fixedAvatarAssetPath; + private string avatarDictionaryToken = string.Empty; + private string selectedImageDictionaryName = string.Empty; + private int minDelaySeconds = 10; + private int maxDelaySeconds = 30; + private int jitterPercent = 20; + private string assetScopeId = Guid.NewGuid().ToString("N"); + + private bool IsChannel => string.Equals(createType, ChannelGroupAutomationTaskObjectTypes.Channel, StringComparison.OrdinalIgnoreCase); + + protected override async Task OnInitializedAsync() + { + await LoadDataAsync(); + ApplyInitialConfig(); + initialized = true; + loading = false; + await PushDraftAsync(); + } + + private async Task LoadDataAsync() + { + categories = (await CategoryManagement.GetAllCategoriesAsync()) + .Where(x => !x.ExcludeFromOperations) + .OrderBy(x => x.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + activeAccounts = (await AccountManagement.GetActiveAccountsAsync()).ToList(); + channelGroups = (await ChannelGroupManagement.GetAllGroupsAsync()).ToList(); + groupCategories = (await GroupCategoryManagement.GetAllCategoriesAsync()).ToList(); + + var dictionaries = await DictionaryService.GetAllAsync(); + textDictionaryNames = dictionaries.Where(x => x.IsEnabled && x.Type == DataDictionaryTypes.Text).Select(x => x.Name).OrderBy(x => x).ToList(); + imageDictionaryNames = dictionaries.Where(x => x.IsEnabled && x.Type == DataDictionaryTypes.Image).Select(x => x.Name).OrderBy(x => x).ToList(); + } + + private void ApplyInitialConfig() + { + if (string.IsNullOrWhiteSpace(InitialConfigJson)) + return; + + try + { + var config = JsonSerializer.Deserialize(InitialConfigJson!); + if (config == null) + return; + + selectedCategoryIds = (config.CategoryIds ?? new List()).Where(x => x > 0).Distinct().ToList(); + createType = string.Equals(config.CreateType, ChannelGroupAutomationTaskObjectTypes.Group, StringComparison.OrdinalIgnoreCase) + ? ChannelGroupAutomationTaskObjectTypes.Group + : ChannelGroupAutomationTaskObjectTypes.Channel; + selectedChannelGroupId = config.ChannelGroupId ?? 0; + selectedGroupCategoryId = config.GroupCategoryId ?? 0; + systemCreatedLimit = Math.Max(1, config.SystemCreatedLimit); + perAccountBatchSize = Math.Max(1, config.PerAccountBatchSize); + titleTemplate = config.TitleTemplate ?? string.Empty; + avatarSource = config.AvatarSource ?? ChannelGroupAutomationAvatarSourceModes.None; + fixedAvatarAssetPath = string.IsNullOrWhiteSpace(config.FixedAvatarAssetPath) ? null : config.FixedAvatarAssetPath.Trim(); + avatarDictionaryToken = config.AvatarDictionaryToken ?? string.Empty; + selectedImageDictionaryName = ExtractDictionaryName(avatarDictionaryToken); + minDelaySeconds = config.MinDelaySeconds; + maxDelaySeconds = config.MaxDelaySeconds; + jitterPercent = config.JitterPercent; + if (!string.IsNullOrWhiteSpace(config.AssetScopeId)) + assetScopeId = config.AssetScopeId.Trim(); + } + catch + { + // 忽略旧配置解析失败,使用默认值。 + } + } + + private async Task OnSelectedCategoriesChanged(IEnumerable values) + { + selectedCategoryIds = (values ?? Array.Empty()).Where(x => x > 0).Distinct().ToList(); + await OnInputsChanged(); + } + + private async Task OnSelectedImageDictionaryChanged() + { + avatarDictionaryToken = string.IsNullOrWhiteSpace(selectedImageDictionaryName) ? string.Empty : $"{{{selectedImageDictionaryName}}}"; + await OnInputsChanged(); + } + + private async Task OnAvatarChanged(IReadOnlyList files) + { + var file = files?.FirstOrDefault(); + if (file == null) + return; + + if (!string.IsNullOrWhiteSpace(fixedAvatarAssetPath)) + await AssetStorage.DeleteAssetAsync(fixedAvatarAssetPath); + + var stored = await AssetStorage.SaveBrowserFileAsync(file, $"task-assets/{assetScopeId}", ImageAssetKind.Avatar); + fixedAvatarAssetPath = stored.AssetPath; + avatarSource = ChannelGroupAutomationAvatarSourceModes.Fixed; + await OnInputsChanged(); + } + + private async Task OnInputsChanged() + { + if (!initialized) + return; + await PushDraftAsync(); + } + + private async Task PushDraftAsync() + { + if (loading) + { + await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "配置加载中")); + return; + } + + var selectedIds = selectedCategoryIds.Where(x => x > 0).Distinct().ToList(); + if (selectedIds.Count == 0) + { + await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "请至少选择一个账号分类")); + return; + } + + titleTemplate = (titleTemplate ?? string.Empty).Trim(); + if (titleTemplate.Length == 0) + { + await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "标题模板不能为空")); + return; + } + + if (systemCreatedLimit < 1) + { + await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "系统创建数量上限必须大于 0")); + return; + } + + if (perAccountBatchSize < 1) + { + await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "每账号本轮处理数量必须大于 0")); + return; + } + + minDelaySeconds = ChannelGroupTaskDelayHelper.NormalizeMinDelay(minDelaySeconds); + maxDelaySeconds = ChannelGroupTaskDelayHelper.NormalizeMaxDelay(minDelaySeconds, maxDelaySeconds); + jitterPercent = ChannelGroupTaskDelayHelper.NormalizeJitterPercent(jitterPercent); + + avatarSource = NormalizeAvatarSource(avatarSource); + if (avatarSource == ChannelGroupAutomationAvatarSourceModes.Fixed && string.IsNullOrWhiteSpace(fixedAvatarAssetPath)) + { + await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "请先上传固定头像")); + return; + } + + if (avatarSource == ChannelGroupAutomationAvatarSourceModes.Dictionary && string.IsNullOrWhiteSpace(avatarDictionaryToken)) + { + await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "请选择图片字典")); + return; + } + + var selectedCategoryNames = categories.Where(x => selectedIds.Contains(x.Id)).Select(x => x.Name).ToList(); + var selectedAccountCount = activeAccounts.Count(x => x.IsActive && x.CategoryId.HasValue && selectedIds.Contains(x.CategoryId.Value) && x.Category?.ExcludeFromOperations != true); + var total = Math.Max(0, selectedAccountCount * Math.Max(1, perAccountBatchSize)); + + var config = new ChannelGroupPrivateCreateTaskConfig + { + CategoryIds = selectedIds, + CategoryNames = selectedCategoryNames, + CreateType = IsChannel ? ChannelGroupAutomationTaskObjectTypes.Channel : ChannelGroupAutomationTaskObjectTypes.Group, + ChannelGroupId = IsChannel && selectedChannelGroupId > 0 ? selectedChannelGroupId : null, + ChannelGroupName = IsChannel && selectedChannelGroupId > 0 ? channelGroups.FirstOrDefault(x => x.Id == selectedChannelGroupId)?.Name : null, + GroupCategoryId = !IsChannel && selectedGroupCategoryId > 0 ? selectedGroupCategoryId : null, + GroupCategoryName = !IsChannel && selectedGroupCategoryId > 0 ? groupCategories.FirstOrDefault(x => x.Id == selectedGroupCategoryId)?.Name : null, + SystemCreatedLimit = systemCreatedLimit, + PerAccountBatchSize = perAccountBatchSize, + MinDelaySeconds = minDelaySeconds, + MaxDelaySeconds = maxDelaySeconds, + JitterPercent = jitterPercent, + TitleTemplate = titleTemplate, + AvatarSource = avatarSource, + FixedAvatarAssetPath = fixedAvatarAssetPath, + AvatarDictionaryToken = string.IsNullOrWhiteSpace(avatarDictionaryToken) ? null : avatarDictionaryToken.Trim(), + AssetScopeId = avatarSource == ChannelGroupAutomationAvatarSourceModes.Fixed ? assetScopeId : null + }; + + var json = JsonSerializer.Serialize(config, new JsonSerializerOptions { WriteIndented = true }); + await DraftChanged.InvokeAsync(new ModuleTaskDraft(total, json, true, null)); + } + + private static string NormalizeAvatarSource(string? value) + { + return string.Equals((value ?? string.Empty).Trim(), ChannelGroupAutomationAvatarSourceModes.Fixed, StringComparison.OrdinalIgnoreCase) + ? ChannelGroupAutomationAvatarSourceModes.Fixed + : string.Equals((value ?? string.Empty).Trim(), ChannelGroupAutomationAvatarSourceModes.Dictionary, StringComparison.OrdinalIgnoreCase) + ? ChannelGroupAutomationAvatarSourceModes.Dictionary + : ChannelGroupAutomationAvatarSourceModes.None; + } + + private static string ExtractDictionaryName(string? token) + { + var text = (token ?? string.Empty).Trim(); + if (text.StartsWith("{") && text.EndsWith("}") && text.Length > 2) + return text[1..^1]; + return string.Empty; + } +} + diff --git a/src/TelegramPanel.Web/Components/Dialogs/ChannelGroupPublicizeTaskEditor.razor b/src/TelegramPanel.Web/Components/Dialogs/ChannelGroupPublicizeTaskEditor.razor new file mode 100644 index 0000000..bc84a11 --- /dev/null +++ b/src/TelegramPanel.Web/Components/Dialogs/ChannelGroupPublicizeTaskEditor.razor @@ -0,0 +1,372 @@ +@inject AccountCategoryManagementService CategoryManagement +@inject AccountManagementService AccountManagement +@inject ChannelGroupManagementService ChannelGroupManagement +@inject GroupCategoryManagementService GroupCategoryManagement +@inject DataDictionaryService DictionaryService +@inject ImageAssetStorageService AssetStorage +@using System.Text.Json +@using TelegramPanel.Data.Entities +@using TelegramPanel.Modules +@using TelegramPanel.Web.Services +@using Microsoft.AspNetCore.Components +@using Microsoft.AspNetCore.Components.Forms + + + @if (loading) + { + + 正在加载配置项... + } + else + { + + @foreach (var item in categories) + { + @item.Name + } + + + + 频道 + 群组 + + + @if (IsChannel) + { + + 未分组 + @foreach (var item in channelGroups) + { + @item.Name + } + + } + else + { + + 未分类 + @foreach (var item in groupCategories) + { + @item.Name + } + + } + + + + + + + + + + + + + + + + + + @if (textDictionaryNames.Count > 0) + { + 可用文本变量:@string.Join("、", textDictionaryNames.Select(x => $"{{{x}}}")) + } + + + 不设置 + 固定上传 + 图片字典变量 + + + @if (avatarSource == ChannelGroupAutomationAvatarSourceModes.Fixed) + { + + + 上传固定头像 + + + @if (!string.IsNullOrWhiteSpace(fixedAvatarAssetPath)) + { + 已保存头像:@fixedAvatarAssetPath + } + } + else if (avatarSource == ChannelGroupAutomationAvatarSourceModes.Dictionary) + { + + -- 请选择图片字典 -- + @foreach (var item in imageDictionaryNames) + { + @item + } + + } + + + + + + + + + + + + + } + + +@code { + [Parameter] public ModuleTaskDraft Draft { get; set; } + [Parameter] public EventCallback DraftChanged { get; set; } + [Parameter] public string? InitialConfigJson { get; set; } + + private bool loading = true; + private bool initialized; + private List categories = new(); + private List activeAccounts = new(); + private List channelGroups = new(); + private List groupCategories = new(); + private List textDictionaryNames = new(); + private List imageDictionaryNames = new(); + + private IEnumerable selectedCategoryIds = Array.Empty(); + private string targetType = ChannelGroupAutomationTaskObjectTypes.Channel; + private int selectedChannelGroupId; + private int selectedGroupCategoryId; + private int minSystemCreatedDays; + private int maxPublicCount = 10; + private int perAccountBatchSize = 1; + private string titleTemplate = string.Empty; + private string descriptionTemplate = string.Empty; + private string usernameTemplate = string.Empty; + private string avatarSource = ChannelGroupAutomationAvatarSourceModes.None; + private string? fixedAvatarAssetPath; + private string avatarDictionaryToken = string.Empty; + private string selectedImageDictionaryName = string.Empty; + private int minDelaySeconds = 10; + private int maxDelaySeconds = 30; + private int jitterPercent = 20; + private string assetScopeId = Guid.NewGuid().ToString("N"); + + private bool IsChannel => string.Equals(targetType, ChannelGroupAutomationTaskObjectTypes.Channel, StringComparison.OrdinalIgnoreCase); + + protected override async Task OnInitializedAsync() + { + await LoadDataAsync(); + ApplyInitialConfig(); + initialized = true; + loading = false; + await PushDraftAsync(); + } + + private async Task LoadDataAsync() + { + categories = (await CategoryManagement.GetAllCategoriesAsync()) + .Where(x => !x.ExcludeFromOperations) + .OrderBy(x => x.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + activeAccounts = (await AccountManagement.GetActiveAccountsAsync()).ToList(); + channelGroups = (await ChannelGroupManagement.GetAllGroupsAsync()).ToList(); + groupCategories = (await GroupCategoryManagement.GetAllCategoriesAsync()).ToList(); + + var dictionaries = await DictionaryService.GetAllAsync(); + textDictionaryNames = dictionaries.Where(x => x.IsEnabled && x.Type == DataDictionaryTypes.Text).Select(x => x.Name).OrderBy(x => x).ToList(); + imageDictionaryNames = dictionaries.Where(x => x.IsEnabled && x.Type == DataDictionaryTypes.Image).Select(x => x.Name).OrderBy(x => x).ToList(); + } + + private void ApplyInitialConfig() + { + if (string.IsNullOrWhiteSpace(InitialConfigJson)) + return; + + try + { + var config = JsonSerializer.Deserialize(InitialConfigJson!); + if (config == null) + return; + + selectedCategoryIds = (config.CategoryIds ?? new List()).Where(x => x > 0).Distinct().ToList(); + targetType = string.Equals(config.TargetType, ChannelGroupAutomationTaskObjectTypes.Group, StringComparison.OrdinalIgnoreCase) + ? ChannelGroupAutomationTaskObjectTypes.Group + : ChannelGroupAutomationTaskObjectTypes.Channel; + selectedChannelGroupId = config.ChannelGroupId ?? 0; + selectedGroupCategoryId = config.GroupCategoryId ?? 0; + minSystemCreatedDays = Math.Max(0, config.MinSystemCreatedDays); + maxPublicCount = Math.Max(1, config.MaxPublicCount); + perAccountBatchSize = Math.Max(1, config.PerAccountBatchSize); + titleTemplate = config.TitleTemplate ?? string.Empty; + descriptionTemplate = config.DescriptionTemplate ?? string.Empty; + usernameTemplate = config.UsernameTemplate ?? string.Empty; + avatarSource = config.AvatarSource ?? ChannelGroupAutomationAvatarSourceModes.None; + fixedAvatarAssetPath = string.IsNullOrWhiteSpace(config.FixedAvatarAssetPath) ? null : config.FixedAvatarAssetPath.Trim(); + avatarDictionaryToken = config.AvatarDictionaryToken ?? string.Empty; + selectedImageDictionaryName = ExtractDictionaryName(avatarDictionaryToken); + minDelaySeconds = config.MinDelaySeconds; + maxDelaySeconds = config.MaxDelaySeconds; + jitterPercent = config.JitterPercent; + if (!string.IsNullOrWhiteSpace(config.AssetScopeId)) + assetScopeId = config.AssetScopeId.Trim(); + } + catch + { + // 忽略旧配置解析失败,使用默认值。 + } + } + + private async Task OnSelectedCategoriesChanged(IEnumerable values) + { + selectedCategoryIds = (values ?? Array.Empty()).Where(x => x > 0).Distinct().ToList(); + await OnInputsChanged(); + } + + private async Task OnSelectedImageDictionaryChanged() + { + avatarDictionaryToken = string.IsNullOrWhiteSpace(selectedImageDictionaryName) ? string.Empty : $"{{{selectedImageDictionaryName}}}"; + await OnInputsChanged(); + } + + private async Task OnAvatarChanged(IReadOnlyList files) + { + var file = files?.FirstOrDefault(); + if (file == null) + return; + + if (!string.IsNullOrWhiteSpace(fixedAvatarAssetPath)) + await AssetStorage.DeleteAssetAsync(fixedAvatarAssetPath); + + var stored = await AssetStorage.SaveBrowserFileAsync(file, $"task-assets/{assetScopeId}", ImageAssetKind.Avatar); + fixedAvatarAssetPath = stored.AssetPath; + avatarSource = ChannelGroupAutomationAvatarSourceModes.Fixed; + await OnInputsChanged(); + } + + private async Task OnInputsChanged() + { + if (!initialized) + return; + await PushDraftAsync(); + } + + private async Task PushDraftAsync() + { + if (loading) + { + await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "配置加载中")); + return; + } + + var selectedIds = selectedCategoryIds.Where(x => x > 0).Distinct().ToList(); + if (selectedIds.Count == 0) + { + await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "请至少选择一个账号分类")); + return; + } + + titleTemplate = (titleTemplate ?? string.Empty).Trim(); + usernameTemplate = (usernameTemplate ?? string.Empty).Trim(); + descriptionTemplate = (descriptionTemplate ?? string.Empty).Trim(); + if (titleTemplate.Length == 0) + { + await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "标题模板不能为空")); + return; + } + + if (usernameTemplate.Length == 0) + { + await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "公开用户名模板不能为空")); + return; + } + + if (maxPublicCount < 1) + { + await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "保持公开最大数量必须大于 0")); + return; + } + + if (perAccountBatchSize < 1) + { + await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "每账号本轮处理数量必须大于 0")); + return; + } + + minSystemCreatedDays = Math.Max(0, minSystemCreatedDays); + minDelaySeconds = ChannelGroupTaskDelayHelper.NormalizeMinDelay(minDelaySeconds); + maxDelaySeconds = ChannelGroupTaskDelayHelper.NormalizeMaxDelay(minDelaySeconds, maxDelaySeconds); + jitterPercent = ChannelGroupTaskDelayHelper.NormalizeJitterPercent(jitterPercent); + + avatarSource = NormalizeAvatarSource(avatarSource); + if (avatarSource == ChannelGroupAutomationAvatarSourceModes.Fixed && string.IsNullOrWhiteSpace(fixedAvatarAssetPath)) + { + await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "请先上传固定头像")); + return; + } + + if (avatarSource == ChannelGroupAutomationAvatarSourceModes.Dictionary && string.IsNullOrWhiteSpace(avatarDictionaryToken)) + { + await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "请选择图片字典")); + return; + } + + var selectedCategoryNames = categories.Where(x => selectedIds.Contains(x.Id)).Select(x => x.Name).ToList(); + var selectedAccountCount = activeAccounts.Count(x => x.IsActive && x.CategoryId.HasValue && selectedIds.Contains(x.CategoryId.Value) && x.Category?.ExcludeFromOperations != true); + var total = Math.Max(0, selectedAccountCount * Math.Max(1, perAccountBatchSize)); + + var config = new ChannelGroupPublicizeTaskConfig + { + CategoryIds = selectedIds, + CategoryNames = selectedCategoryNames, + TargetType = IsChannel ? ChannelGroupAutomationTaskObjectTypes.Channel : ChannelGroupAutomationTaskObjectTypes.Group, + ChannelGroupId = IsChannel && selectedChannelGroupId > 0 ? selectedChannelGroupId : null, + ChannelGroupName = IsChannel && selectedChannelGroupId > 0 ? channelGroups.FirstOrDefault(x => x.Id == selectedChannelGroupId)?.Name : null, + GroupCategoryId = !IsChannel && selectedGroupCategoryId > 0 ? selectedGroupCategoryId : null, + GroupCategoryName = !IsChannel && selectedGroupCategoryId > 0 ? groupCategories.FirstOrDefault(x => x.Id == selectedGroupCategoryId)?.Name : null, + MinSystemCreatedDays = minSystemCreatedDays, + MaxPublicCount = maxPublicCount, + PerAccountBatchSize = perAccountBatchSize, + MinDelaySeconds = minDelaySeconds, + MaxDelaySeconds = maxDelaySeconds, + JitterPercent = jitterPercent, + TitleTemplate = titleTemplate, + DescriptionTemplate = descriptionTemplate, + UsernameTemplate = usernameTemplate, + AvatarSource = avatarSource, + FixedAvatarAssetPath = fixedAvatarAssetPath, + AvatarDictionaryToken = string.IsNullOrWhiteSpace(avatarDictionaryToken) ? null : avatarDictionaryToken.Trim(), + AssetScopeId = avatarSource == ChannelGroupAutomationAvatarSourceModes.Fixed ? assetScopeId : null + }; + + var json = JsonSerializer.Serialize(config, new JsonSerializerOptions { WriteIndented = true }); + await DraftChanged.InvokeAsync(new ModuleTaskDraft(total, json, true, null)); + } + + private static string NormalizeAvatarSource(string? value) + { + return string.Equals((value ?? string.Empty).Trim(), ChannelGroupAutomationAvatarSourceModes.Fixed, StringComparison.OrdinalIgnoreCase) + ? ChannelGroupAutomationAvatarSourceModes.Fixed + : string.Equals((value ?? string.Empty).Trim(), ChannelGroupAutomationAvatarSourceModes.Dictionary, StringComparison.OrdinalIgnoreCase) + ? ChannelGroupAutomationAvatarSourceModes.Dictionary + : ChannelGroupAutomationAvatarSourceModes.None; + } + + private static string ExtractDictionaryName(string? token) + { + var text = (token ?? string.Empty).Trim(); + if (text.StartsWith("{") && text.EndsWith("}") && text.Length > 2) + return text[1..^1]; + return string.Empty; + } +} + diff --git a/src/TelegramPanel.Web/Components/Dialogs/CreateBatchTaskDialog.razor b/src/TelegramPanel.Web/Components/Dialogs/CreateBatchTaskDialog.razor index 23f407d..ab3256e 100644 --- a/src/TelegramPanel.Web/Components/Dialogs/CreateBatchTaskDialog.razor +++ b/src/TelegramPanel.Web/Components/Dialogs/CreateBatchTaskDialog.razor @@ -1,5 +1,6 @@ -@namespace TelegramPanel.Web.Components.Dialogs +@namespace TelegramPanel.Web.Components.Dialogs @inject BatchTaskManagementService TaskManagement +@inject ScheduledTaskService ScheduledTaskManagement @inject TelegramPanel.Web.Modules.ModuleContributionRegistry Contributions @inject ISnackbar Snackbar @inject IDialogService DialogService @@ -7,25 +8,28 @@ @using System.Text.Json @using Microsoft.AspNetCore.Components @using TelegramPanel.Data.Entities +@using TelegramPanel.Modules +@using TelegramPanel.Web.Modules +@using TelegramPanel.Web.Services - 任务会提交到后台执行,可在【任务中心】查看进度与结果。 + 立即执行会创建一条后台执行记录;Cron 计划会在任务中心的【计划任务】区域持续调度。 - - @foreach (var c in AvailableCategories) - { - @GetCategoryDisplayName(c) - } - + + @foreach (var c in AvailableCategories) + { + @GetCategoryDisplayName(c) + } + - + @foreach (var opt in AvailableOptions) { @opt.DisplayName @@ -34,11 +38,38 @@ - @if (CurrentOption != null && !string.IsNullOrWhiteSpace(CurrentOption.Description)) + + 立即执行 + Cron 计划 + + + @if (!string.IsNullOrWhiteSpace(CurrentOption?.Description)) { @CurrentOption.Description } + @if (IsScheduledMode) + { + + Cron 按服务器本地时区解析:@TimeZoneInfo.Local.Id + + + + + + @foreach (var preset in CronPresets) + { + + @preset.Label + + } + + } + @if (CurrentOption != null && !string.IsNullOrWhiteSpace(CurrentOption.CreateRoute)) { @@ -56,7 +87,7 @@ else if (CurrentOption != null && !string.IsNullOrWhiteSpace(CurrentOption.EditorComponentType) && editorComponentType != null) { - 该任务由模块提供自定义创建器。 + 该任务由模块提供自定义配置表单。 @@ -72,10 +103,18 @@ 该任务类型暂无专用创建表单。你可以填写通用配置(JSON 字符串)提交任务;对应模块需要在后台实现该 TaskType 的执行器。 - - } @@ -88,6 +127,10 @@ 提交中... } + else if (IsScheduledMode) + { + 保存计划 + } else { 提交任务 @@ -98,28 +141,47 @@ @code { + private sealed record CronPresetItem(string Label, string Expression); + + private static class SubmissionModes + { + public const string Once = "once"; + public const string Scheduled = "scheduled"; + } + + private static readonly IReadOnlyList CronPresets = new[] + { + new CronPresetItem("每30分钟", "*/30 * * * *"), + new CronPresetItem("每小时", "0 * * * *"), + new CronPresetItem("每天9点", "0 9 * * *"), + new CronPresetItem("每天12点", "0 12 * * *"), + new CronPresetItem("每天21点", "0 21 * * *") + }; + [CascadingParameter] private MudDialogInstance MudDialog { get; set; } = default!; - private string _selectedCategory = ""; + private string _selectedCategory = string.Empty; private string selectedCategory { get => _selectedCategory; set { - value = (value ?? "").Trim(); + value = (value ?? string.Empty).Trim(); if (string.Equals(_selectedCategory, value, StringComparison.OrdinalIgnoreCase)) return; + _selectedCategory = value; EnsureTaskTypeValid(); } } - private string _selectedTaskType = ""; + + private string _selectedTaskType = string.Empty; private string selectedTaskType { get => _selectedTaskType; set { - value = (value ?? "").Trim(); + value = (value ?? string.Empty).Trim(); if (string.Equals(_selectedTaskType, value, StringComparison.OrdinalIgnoreCase)) return; @@ -128,46 +190,46 @@ } } + private string submissionMode = SubmissionModes.Once; + private string cronExpression = "0 9 * * *"; private bool creating; - - // generic task config - private string genericConfigJson = ""; - private int genericTotal = 0; - - // module editor draft - private TelegramPanel.Modules.ModuleTaskDraft editorDraft = new(0, null, false, null); + private string genericConfigJson = string.Empty; + private int genericTotal; + private ModuleTaskDraft editorDraft = new(0, null, false, null); private Type? editorComponentType; private Dictionary editorParameters = new(); + private bool IsScheduledMode => string.Equals(submissionMode, SubmissionModes.Scheduled, StringComparison.OrdinalIgnoreCase); + private IEnumerable AvailableCategories => Contributions.Tasks - .Select(x => (x.Definition.Category ?? "").Trim()) + .Select(x => (x.Definition.Category ?? string.Empty).Trim()) .Where(x => x.Length > 0) .Where(x => !IsSystemCategory(x)) .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(x => x, StringComparer.OrdinalIgnoreCase); - private IEnumerable AvailableOptions => + private IEnumerable AvailableOptions => Contributions.Tasks .Select(x => x.Definition) .Where(o => !IsSystemCategory(o.Category)) .Where(o => string.Equals(o.Category, selectedCategory, StringComparison.OrdinalIgnoreCase)) .OrderBy(o => o.Order) - .ThenBy(o => o.DisplayName); + .ThenBy(o => o.DisplayName, StringComparer.OrdinalIgnoreCase); - private TelegramPanel.Modules.ModuleTaskDefinition? CurrentOption => + private ModuleTaskDefinition? CurrentOption => !string.IsNullOrWhiteSpace(selectedTaskType) && Contributions.TaskTypeToDefinition.TryGetValue(selectedTaskType, out var reg) ? reg.Definition : null; - protected override async Task OnInitializedAsync() + protected override Task OnInitializedAsync() { if (string.IsNullOrWhiteSpace(selectedCategory)) selectedCategory = AvailableCategories.FirstOrDefault() ?? "user"; EnsureTaskTypeValid(); - await Task.CompletedTask; + return Task.CompletedTask; } private void EnsureTaskTypeValid() @@ -175,8 +237,8 @@ if (AvailableOptions.All(o => !string.Equals(o.TaskType, selectedTaskType, StringComparison.OrdinalIgnoreCase))) { selectedTaskType = AvailableOptions.FirstOrDefault()?.TaskType - ?? Contributions.Tasks.FirstOrDefault()?.Definition.TaskType - ?? ""; + ?? Contributions.Tasks.FirstOrDefault(x => !IsSystemCategory(x.Definition.Category))?.Definition.TaskType + ?? string.Empty; } } @@ -189,37 +251,42 @@ }; private static bool IsSystemCategory(string? category) => - string.Equals((category ?? "").Trim(), "system", StringComparison.OrdinalIgnoreCase); + string.Equals((category ?? string.Empty).Trim(), "system", StringComparison.OrdinalIgnoreCase); private void ResolveEditorComponent() { editorComponentType = null; editorParameters = new Dictionary(); - editorDraft = new TelegramPanel.Modules.ModuleTaskDraft(0, null, false, null); + editorDraft = new ModuleTaskDraft(0, null, false, null); - var opt = CurrentOption; - if (opt == null || string.IsNullOrWhiteSpace(opt.EditorComponentType)) + var option = CurrentOption; + if (option == null || string.IsNullOrWhiteSpace(option.EditorComponentType)) return; - if (!Contributions.TaskTypeToDefinition.TryGetValue(opt.TaskType, out var reg)) + if (!Contributions.TaskTypeToDefinition.TryGetValue(option.TaskType, out var reg)) return; editorComponentType = ModuleTaskEditorComponentResolver.ResolveCreateEditor(reg); - if (editorComponentType == null) return; editorParameters = new Dictionary { ["Draft"] = editorDraft, - ["DraftChanged"] = EventCallback.Factory.Create(this, OnEditorDraftChanged) + ["DraftChanged"] = EventCallback.Factory.Create(this, OnEditorDraftChanged) }; } - private void OnEditorDraftChanged(TelegramPanel.Modules.ModuleTaskDraft draft) + private void OnEditorDraftChanged(ModuleTaskDraft draft) { editorDraft = draft; } + + private void ApplyCronPreset(string expression) + { + cronExpression = expression; + } + private async Task Create() { if (creating) @@ -231,102 +298,68 @@ return; } - if (CurrentOption != null && !string.IsNullOrWhiteSpace(CurrentOption.EditorComponentType) && editorComponentType != null) + string? config; + int total; + + try { - if (!editorDraft.CanSubmit) - { - Snackbar.Add(string.IsNullOrWhiteSpace(editorDraft.ValidationError) ? "请先完善任务配置" : editorDraft.ValidationError!, Severity.Warning); - return; - } - - var config = string.IsNullOrWhiteSpace(editorDraft.Config) ? null : editorDraft.Config.Trim(); - if (!string.IsNullOrWhiteSpace(config)) - { - try - { - JsonDocument.Parse(config); - } - catch (Exception ex) - { - Snackbar.Add($"配置 JSON 无效:{ex.Message}", Severity.Warning); - return; - } - } - - var total = editorDraft.Total < 0 ? 0 : editorDraft.Total; - bool? confirm = await DialogService.ShowMessageBox( - "确认提交任务", - $"将提交任务类型:{selectedTaskType},总数:{total}。是否继续?", - yesText: "继续", cancelText: "取消"); - - if (confirm != true) - return; - - creating = true; - try - { - var task = new BatchTask - { - TaskType = selectedTaskType, - Total = total, - Completed = 0, - Failed = 0, - Config = config - }; - - var created = await TaskManagement.CreateTaskAsync(task); - Snackbar.Add($"已提交后台任务 #{created.Id}({selectedTaskType})。请到【任务中心】查看进度。", Severity.Success); - MudDialog.Close(DialogResult.Ok(created.Id)); - } - catch (Exception ex) - { - Snackbar.Add($"提交失败:{ex.Message}", Severity.Error); - } - finally - { - creating = false; - } - + (config, total) = ResolveSubmissionDraft(); + } + catch (Exception ex) + { + Snackbar.Add(ex.Message, Severity.Warning); return; } - // Generic task create - if (!string.IsNullOrWhiteSpace(genericConfigJson)) - { - try - { - JsonDocument.Parse(genericConfigJson); - } - catch (Exception ex) - { - Snackbar.Add($"配置 JSON 无效:{ex.Message}", Severity.Warning); - return; - } - } - - bool? genericConfirm = await DialogService.ShowMessageBox( - "确认提交任务", - $"将提交任务类型:{selectedTaskType}。是否继续?", - yesText: "继续", cancelText: "取消"); - - if (genericConfirm != true) - return; - creating = true; try { + if (IsScheduledMode) + { + bool? confirm = await DialogService.ShowMessageBox( + "确认创建计划任务", + $"将保存计划任务:{selectedTaskType},Cron:{cronExpression}。是否继续?", + yesText: "继续", cancelText: "取消"); + + if (confirm != true) + return; + + var scheduledTask = new ScheduledTask + { + TaskType = selectedTaskType, + Total = total, + ConfigJson = config, + CronExpression = cronExpression.Trim(), + Status = ScheduledTaskStatuses.Enabled, + OwnedAssetScopeId = TaskAssetScopeHelper.GetAssetScopeId(config) + }; + + var created = await ScheduledTaskManagement.CreateAsync(scheduledTask); + Snackbar.Add($"已创建计划任务 #{created.Id}({selectedTaskType})。", Severity.Success); + MudDialog.Close(DialogResult.Ok(created.Id)); + return; + } + + bool? taskConfirm = await DialogService.ShowMessageBox( + "确认提交任务", + $"将提交任务类型:{selectedTaskType},总数:{total}。是否继续?", + yesText: "继续", cancelText: "取消"); + + if (taskConfirm != true) + return; + var task = new BatchTask { TaskType = selectedTaskType, - Total = genericTotal, + Total = total, Completed = 0, Failed = 0, - Config = string.IsNullOrWhiteSpace(genericConfigJson) ? null : genericConfigJson.Trim() + Config = config }; - var created = await TaskManagement.CreateTaskAsync(task); - Snackbar.Add($"已提交后台任务 #{created.Id}({selectedTaskType})。请到【任务中心】查看进度。", Severity.Success); - MudDialog.Close(DialogResult.Ok(created.Id)); + var createdTask = await TaskManagement.CreateTaskAsync(task); + Snackbar.Add($"已提交后台任务 #{createdTask.Id}({selectedTaskType})。请到【任务中心】查看进度。", Severity.Success); + MudDialog.Close(DialogResult.Ok(createdTask.Id)); } catch (Exception ex) { @@ -338,6 +371,33 @@ } } + private (string? Config, int Total) ResolveSubmissionDraft() + { + if (string.IsNullOrWhiteSpace(selectedTaskType)) + throw new InvalidOperationException("请先选择任务类型"); + + if (IsScheduledMode && string.IsNullOrWhiteSpace(cronExpression)) + throw new InvalidOperationException("请填写 Cron 表达式"); + + if (editorComponentType != null) + { + if (!editorDraft.CanSubmit) + throw new InvalidOperationException(string.IsNullOrWhiteSpace(editorDraft.ValidationError) ? "请先完善任务配置" : editorDraft.ValidationError!); + + var editorConfig = string.IsNullOrWhiteSpace(editorDraft.Config) ? null : editorDraft.Config.Trim(); + if (!string.IsNullOrWhiteSpace(editorConfig)) + JsonDocument.Parse(editorConfig); + + return (editorConfig, Math.Max(0, editorDraft.Total)); + } + + var genericConfig = string.IsNullOrWhiteSpace(genericConfigJson) ? null : genericConfigJson.Trim(); + if (!string.IsNullOrWhiteSpace(genericConfig)) + JsonDocument.Parse(genericConfig); + + return (genericConfig, Math.Max(0, genericTotal)); + } + private void NavigateTo(string url) { MudDialog.Close(); @@ -346,11 +406,10 @@ private async Task OpenCreateDialog(string route) { - route = (route ?? "").Trim(); + route = (route ?? string.Empty).Trim(); if (route.Length == 0) return; - // 仅对模块扩展页面提供“窗口打开”:/ext/{ModuleId}/{PageKey} if (!TryParseExtRoute(route, out var moduleId, out var pageKey)) { Snackbar.Add("该任务未提供可窗口打开的模块页面,请使用“前往页面”。", Severity.Info); @@ -370,19 +429,15 @@ private static bool TryParseExtRoute(string route, out string moduleId, out string pageKey) { - moduleId = ""; - pageKey = ""; + moduleId = string.Empty; + pageKey = string.Empty; - route = (route ?? "").Trim(); + route = (route ?? string.Empty).Trim(); if (!route.StartsWith("/ext/", StringComparison.OrdinalIgnoreCase)) return false; - // /ext/{moduleId}/{pageKey} var parts = route.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - if (parts.Length < 3) - return false; - - if (!string.Equals(parts[0], "ext", StringComparison.OrdinalIgnoreCase)) + if (parts.Length < 3 || !string.Equals(parts[0], "ext", StringComparison.OrdinalIgnoreCase)) return false; moduleId = parts[1]; diff --git a/src/TelegramPanel.Web/Components/Dialogs/DataDictionaryEditDialog.razor b/src/TelegramPanel.Web/Components/Dialogs/DataDictionaryEditDialog.razor new file mode 100644 index 0000000..4a92eeb --- /dev/null +++ b/src/TelegramPanel.Web/Components/Dialogs/DataDictionaryEditDialog.razor @@ -0,0 +1,323 @@ +@namespace TelegramPanel.Web.Components.Dialogs +@inject DataDictionaryService DictionaryService +@inject ImageAssetStorageService AssetStorage +@inject ISnackbar Snackbar +@using Microsoft.AspNetCore.Components +@using Microsoft.AspNetCore.Components.Forms +@using TelegramPanel.Data.Entities +@using TelegramPanel.Web.Services + + + + @if (loading) + { + + + 正在加载字典配置... + + } + else if (!string.IsNullOrWhiteSpace(loadError)) + { + + @loadError + + } + else + { + + + + + + + + + + + + + + + + 文本字典 + 图片字典 + + + + + 随机 + 队列轮询 + + + + 启用字典 + + + + @if (IsTextDictionary) + { + + } + else + { + + 图片字典支持一次上传多张图;删除字典或删除字典项时会同步清理图片文件。 + + + + + + 选择图片 + + + + + @if (existingImages.Count > 0) + { + 现有图片 + + + 文件名 + 预览 + 操作 + + + @context.FileName + 查看 + + + + + + } + + @if (newImageFiles.Count > 0) + { + 待上传图片 + + + 文件名 + 大小 + 操作 + + + @context.FileName + @FormatBytes(context.Size) + + + + + + } + } + + } + + + 关闭 + + @if (saving) + { + + 保存中... + } + else + { + 保存字典 + } + + + + +@code { + private sealed record ExistingImageRow(int Id, string AssetPath, string FileName); + private sealed record PendingImageRow(int Index, string FileName, long Size); + + [CascadingParameter] private MudDialogInstance MudDialog { get; set; } = default!; + [Parameter] public int? DictionaryId { get; set; } + [Parameter] public string? InitialType { get; set; } + + private bool loading = true; + private bool saving; + private string? loadError; + private string name = string.Empty; + private string displayName = string.Empty; + private string? description; + private string type = DataDictionaryTypes.Text; + private string readMode = DataDictionaryReadModes.Random; + private bool isEnabled = true; + private string textLines = string.Empty; + private List existingImages = new(); + private List newImageFiles = new(); + private readonly string uploadScope = $"dictionaries/draft-{Guid.NewGuid():N}"; + + private bool IsEdit => DictionaryId.HasValue && DictionaryId.Value > 0; + private bool IsTextDictionary => string.Equals(type, DataDictionaryTypes.Text, StringComparison.OrdinalIgnoreCase); + + protected override async Task OnInitializedAsync() + { + try + { + type = string.Equals((InitialType ?? string.Empty).Trim(), DataDictionaryTypes.Image, StringComparison.OrdinalIgnoreCase) + ? DataDictionaryTypes.Image + : DataDictionaryTypes.Text; + + if (!IsEdit) + return; + + var dictionary = await DictionaryService.GetAsync(DictionaryId!.Value); + if (dictionary == null) + { + loadError = "字典不存在或已被删除。"; + return; + } + + name = dictionary.Name; + displayName = dictionary.DisplayName; + description = dictionary.Description; + type = dictionary.Type; + readMode = dictionary.ReadMode; + isEnabled = dictionary.IsEnabled; + + if (string.Equals(dictionary.Type, DataDictionaryTypes.Text, StringComparison.OrdinalIgnoreCase)) + { + textLines = string.Join(Environment.NewLine, + dictionary.Items + .Where(x => x.IsEnabled && !string.IsNullOrWhiteSpace(x.TextValue)) + .OrderBy(x => x.SortOrder) + .ThenBy(x => x.Id) + .Select(x => x.TextValue)); + } + else + { + existingImages = dictionary.Items + .Where(x => x.IsEnabled && !string.IsNullOrWhiteSpace(x.AssetPath)) + .OrderBy(x => x.SortOrder) + .ThenBy(x => x.Id) + .Select(x => new ExistingImageRow(x.Id, x.AssetPath!, x.FileName ?? $"image-{x.Id}.jpg")) + .ToList(); + } + } + catch (Exception ex) + { + loadError = ex.Message; + } + finally + { + loading = false; + } + } + + private Task OnFilesChanged(IReadOnlyList files) + { + if (files != null) + newImageFiles.AddRange(files.Where(x => x != null)); + return Task.CompletedTask; + } + + private void RemoveExistingImage(int itemId) + { + existingImages.RemoveAll(x => x.Id == itemId); + } + + private void RemovePendingImage(int index) + { + if (index >= 0 && index < newImageFiles.Count) + newImageFiles.RemoveAt(index); + } + + private IEnumerable GetPendingImageRows() + { + for (var index = 0; index < newImageFiles.Count; index++) + { + var file = newImageFiles[index]; + yield return new PendingImageRow(index, file.Name, file.Size); + } + } + + private async Task Save() + { + if (saving) + return; + + saving = true; + var savedAssets = new List(); + try + { + if (string.IsNullOrWhiteSpace(name)) + throw new InvalidOperationException("字典名称不能为空"); + if (string.IsNullOrWhiteSpace(displayName)) + throw new InvalidOperationException("显示名称不能为空"); + + if (IsTextDictionary) + { + var items = textLines + .Replace("\r\n", "\n", StringComparison.Ordinal) + .Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(x => new DataDictionaryTextItemInput(x)) + .ToList(); + + var saved = await DictionaryService.SaveTextDictionaryAsync(DictionaryId, name, displayName, description, readMode, isEnabled, items); + Snackbar.Add($"字典 {saved.DisplayName} 已保存", Severity.Success); + MudDialog.Close(DialogResult.Ok(saved.Id)); + return; + } + + var scope = IsEdit ? $"dictionaries/{DictionaryId!.Value}" : uploadScope; + var imageInputs = new List(); + foreach (var file in newImageFiles) + { + var asset = await AssetStorage.SaveBrowserFileAsync(file, scope, ImageAssetKind.General); + savedAssets.Add(asset.AssetPath); + imageInputs.Add(new DataDictionaryImageItemInput(asset.AssetPath, asset.FileName)); + } + + var keepItemIds = existingImages.Select(x => x.Id).ToList(); + var savedDictionary = await DictionaryService.SaveImageDictionaryAsync(DictionaryId, name, displayName, description, readMode, isEnabled, keepItemIds, imageInputs); + Snackbar.Add($"字典 {savedDictionary.DisplayName} 已保存", Severity.Success); + MudDialog.Close(DialogResult.Ok(savedDictionary.Id)); + } + catch (Exception ex) + { + foreach (var assetPath in savedAssets) + await AssetStorage.DeleteAssetAsync(assetPath); + + Snackbar.Add($"保存失败:{ex.Message}", Severity.Error); + } + finally + { + saving = false; + } + } + + private static string FormatBytes(long bytes) + { + if (bytes < 1024) + return $"{bytes} B"; + if (bytes < 1024 * 1024) + return $"{bytes / 1024d:0.##} KB"; + return $"{bytes / 1024d / 1024d:0.##} MB"; + } + + private static string GetImageUrl(string assetPath) + { + return "/" + (assetPath ?? string.Empty).TrimStart('/').Replace('\\', '/'); + } + + private void Cancel() => MudDialog.Close(); +} diff --git a/src/TelegramPanel.Web/Components/Dialogs/EditScheduledTaskDialog.razor b/src/TelegramPanel.Web/Components/Dialogs/EditScheduledTaskDialog.razor new file mode 100644 index 0000000..e3108f6 --- /dev/null +++ b/src/TelegramPanel.Web/Components/Dialogs/EditScheduledTaskDialog.razor @@ -0,0 +1,259 @@ +@namespace TelegramPanel.Web.Components.Dialogs +@inject ScheduledTaskService ScheduledTaskManagement +@inject TelegramPanel.Web.Modules.ModuleContributionRegistry Contributions +@inject ISnackbar Snackbar +@inject IDialogService DialogService +@using System.Reflection +@using System.Text.Json +@using Microsoft.AspNetCore.Components +@using TelegramPanel.Data.Entities +@using TelegramPanel.Modules +@using TelegramPanel.Web.Modules +@using TelegramPanel.Web.Services + + + + @if (loading) + { + + + 正在加载计划任务配置... + + } + else if (!string.IsNullOrWhiteSpace(loadError)) + { + + @loadError + + } + else + { + + + Cron 按服务器本地时区解析:@TimeZoneInfo.Local.Id。 + + + + + + @foreach (var preset in CronPresets) + { + + @preset.Label + + } + + + @if (editorComponentType != null) + { + + + @if (!string.IsNullOrWhiteSpace(draft.ValidationError)) + { + @draft.ValidationError + } + } + else if (!hasDedicatedCreateRoute) + { + + + + } + else + { + + 该任务使用独立页面创建,当前不支持在此窗口编辑配置。 + + } + + } + + + 关闭 + + @if (saving) + { + + 保存中... + } + else + { + 保存计划任务 + } + + + + +@code { + private sealed record CronPresetItem(string Label, string Expression); + + private static readonly IReadOnlyList CronPresets = new[] + { + new CronPresetItem("每30分钟", "*/30 * * * *"), + new CronPresetItem("每小时", "0 * * * *"), + new CronPresetItem("每天9点", "0 9 * * *"), + new CronPresetItem("每天12点", "0 12 * * *"), + new CronPresetItem("每天21点", "0 21 * * *") + }; + + [CascadingParameter] private MudDialogInstance MudDialog { get; set; } = default!; + [Parameter] public int TaskId { get; set; } + + private ScheduledTask? scheduledTask; + private RegisteredTaskDefinition? taskDefinition; + private Type? editorComponentType; + private Dictionary editorParameters = new(); + private ModuleTaskDraft draft = new(0, null, false, null); + private bool loading = true; + private bool saving; + private string? loadError; + private string cronExpression = string.Empty; + private string genericConfigJson = string.Empty; + private int genericTotal; + + private bool hasDedicatedCreateRoute => !string.IsNullOrWhiteSpace(taskDefinition?.Definition.CreateRoute); + + private bool CanSave => + scheduledTask != null + && !string.IsNullOrWhiteSpace(cronExpression) + && !hasDedicatedCreateRoute + && (editorComponentType == null || draft.CanSubmit); + + protected override async Task OnInitializedAsync() + { + try + { + if (TaskId <= 0) + { + loadError = "计划任务参数无效。"; + return; + } + + scheduledTask = await ScheduledTaskManagement.GetAsync(TaskId); + if (scheduledTask == null) + { + loadError = "计划任务不存在或已被删除。"; + return; + } + + cronExpression = scheduledTask.CronExpression; + genericConfigJson = scheduledTask.ConfigJson ?? string.Empty; + genericTotal = Math.Max(0, scheduledTask.Total); + + if (Contributions.TaskTypeToDefinition.TryGetValue((scheduledTask.TaskType ?? string.Empty).Trim(), out var reg)) + { + taskDefinition = reg; + editorComponentType = ModuleTaskEditorComponentResolver.ResolveEditEditor(reg); + if (editorComponentType != null) + editorParameters = BuildEditorParameters(editorComponentType, scheduledTask); + } + } + catch (Exception ex) + { + loadError = ex.Message; + } + finally + { + loading = false; + } + } + + private Dictionary BuildEditorParameters(Type componentType, ScheduledTask task) + { + var parameters = new Dictionary + { + ["Draft"] = draft, + ["DraftChanged"] = EventCallback.Factory.Create(this, OnDraftChanged) + }; + + AddOptionalParameter(parameters, componentType, "InitialConfigJson", task.ConfigJson); + AddOptionalParameter(parameters, componentType, "TaskId", task.Id); + return parameters; + } + + private static void AddOptionalParameter(Dictionary parameters, Type componentType, string parameterName, object? value) + { + var property = componentType.GetProperty(parameterName, BindingFlags.Public | BindingFlags.Instance); + if (property == null) + return; + + parameters[parameterName] = value; + } + + private void OnDraftChanged(ModuleTaskDraft value) + { + draft = value; + } + + private void ApplyCronPreset(string expression) + { + cronExpression = expression; + } + + private async Task Save() + { + if (!CanSave || scheduledTask == null || saving) + return; + + bool? confirm = await DialogService.ShowMessageBox( + "确认保存", + $"将更新计划任务 #{scheduledTask.Id} 的配置,是否继续?", + yesText: "保存", cancelText: "取消"); + + if (confirm != true) + return; + + saving = true; + try + { + var config = genericConfigJson?.Trim(); + var total = Math.Max(0, genericTotal); + + if (editorComponentType != null) + { + if (!draft.CanSubmit) + throw new InvalidOperationException(string.IsNullOrWhiteSpace(draft.ValidationError) ? "请先完善任务配置" : draft.ValidationError); + + config = draft.Config?.Trim(); + total = Math.Max(0, draft.Total); + } + else if (!string.IsNullOrWhiteSpace(config)) + { + JsonDocument.Parse(config); + } + + scheduledTask.CronExpression = cronExpression.Trim(); + scheduledTask.ConfigJson = string.IsNullOrWhiteSpace(config) ? null : config; + scheduledTask.Total = total; + scheduledTask.OwnedAssetScopeId = TaskAssetScopeHelper.GetAssetScopeId(scheduledTask.ConfigJson); + + await ScheduledTaskManagement.UpdateAsync(scheduledTask); + Snackbar.Add($"计划任务 #{scheduledTask.Id} 已更新", Severity.Success); + MudDialog.Close(DialogResult.Ok(scheduledTask.Id)); + } + catch (Exception ex) + { + Snackbar.Add($"保存失败:{ex.Message}", Severity.Error); + } + finally + { + saving = false; + } + } + + private void Cancel() => MudDialog.Close(); +} + diff --git a/src/TelegramPanel.Web/Components/Layout/NavMenu.razor b/src/TelegramPanel.Web/Components/Layout/NavMenu.razor index 9352e23..630db95 100644 --- a/src/TelegramPanel.Web/Components/Layout/NavMenu.razor +++ b/src/TelegramPanel.Web/Components/Layout/NavMenu.razor @@ -1,4 +1,4 @@ -@inject TelegramPanel.Web.Modules.ModuleContributionRegistry Contributions +@inject TelegramPanel.Web.Modules.ModuleContributionRegistry Contributions @@ -33,6 +33,10 @@ 任务中心 + + 数据字典 + + 模块管理 @@ -71,3 +75,5 @@ 退出登录 + + diff --git a/src/TelegramPanel.Web/Components/Pages/ChannelCreate.razor b/src/TelegramPanel.Web/Components/Pages/ChannelCreate.razor index f31c624..b7dd59b 100644 --- a/src/TelegramPanel.Web/Components/Pages/ChannelCreate.razor +++ b/src/TelegramPanel.Web/Components/Pages/ChannelCreate.razor @@ -1,4 +1,4 @@ -@page "/channels/create" +@page "/channels/create" @inject IChannelService ChannelService @inject AccountManagementService AccountManagement @inject TelegramPanel.Core.Services.AccountRiskService RiskService @@ -185,6 +185,7 @@ } // 保存到数据库 + var now = DateTime.UtcNow; var channel = new Channel { TelegramId = channelInfo.TelegramId, @@ -196,8 +197,9 @@ About = channelAbout, CreatorAccountId = selectedAccountId, GroupId = selectedGroupId > 0 ? selectedGroupId : null, - CreatedAt = DateTime.UtcNow, - SyncedAt = DateTime.UtcNow + CreatedAt = now, + SyncedAt = now, + SystemCreatedAtUtc = now }; await ChannelManagement.CreateOrUpdateChannelAsync(channel); @@ -232,3 +234,5 @@ return namePart; } } + + diff --git a/src/TelegramPanel.Web/Components/Pages/DataDictionaries.razor b/src/TelegramPanel.Web/Components/Pages/DataDictionaries.razor new file mode 100644 index 0000000..fd244b8 --- /dev/null +++ b/src/TelegramPanel.Web/Components/Pages/DataDictionaries.razor @@ -0,0 +1,208 @@ +@page "/data-dictionaries" +@inject DataDictionaryService DictionaryService +@inject ISnackbar Snackbar +@inject IDialogService DialogService +@using TelegramPanel.Data.Entities + +数据字典 - Telegram Panel + +数据字典管理 + + + + + + + + 新建文本字典 + + + 新建图片字典 + + + + + + + + + + + 显示名称 + 变量名 + 类型 + 读取模式 + 状态 + 内容数 + 轮询游标 + 更新时间 + 操作 + + + @context.DisplayName + @($"{" + context.Name + "}") + + + @(IsImageDictionary(context) ? "图片" : "文本") + + + @(IsQueueMode(context) ? "队列轮询" : "随机") + + + @(context.IsEnabled ? "启用" : "停用") + + + @context.Items.Count(x => x.IsEnabled) + @context.NextIndex + + + + + + + + + + + + 暂无数据字典 + + + + + +@code { + private List dictionaries = new(); + private bool loading = true; + private string searchText = string.Empty; + + private IEnumerable FilteredDictionaries => + dictionaries.Where(MatchSearch) + .OrderBy(x => x.DisplayName, StringComparer.OrdinalIgnoreCase) + .ThenBy(x => x.Name, StringComparer.OrdinalIgnoreCase); + + protected override async Task OnInitializedAsync() + { + await LoadDictionaries(); + } + + private async Task LoadDictionaries() + { + loading = true; + try + { + dictionaries = (await DictionaryService.GetAllAsync()).ToList(); + } + catch (Exception ex) + { + Snackbar.Add($"加载字典失败:{ex.Message}", Severity.Error); + } + finally + { + loading = false; + } + } + + private bool MatchSearch(DataDictionary dictionary) + { + var text = (searchText ?? string.Empty).Trim(); + if (text.Length == 0) + return true; + + return (dictionary.Name?.Contains(text, StringComparison.OrdinalIgnoreCase) ?? false) + || (dictionary.DisplayName?.Contains(text, StringComparison.OrdinalIgnoreCase) ?? false) + || (dictionary.Description?.Contains(text, StringComparison.OrdinalIgnoreCase) ?? false); + } + + private async Task OpenCreateDialog(string type) + { + var parameters = new DialogParameters + { + ["InitialType"] = type + }; + var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Large, FullWidth = true }; + var dialog = DialogService.Show(type == DataDictionaryTypes.Image ? "新建图片字典" : "新建文本字典", parameters, options)!; + var result = await dialog.Result; + if (result is { Canceled: false }) + await LoadDictionaries(); + } + + private async Task OpenEditDialog(DataDictionary dictionary) + { + var parameters = new DialogParameters + { + ["DictionaryId"] = dictionary.Id, + ["InitialType"] = dictionary.Type + }; + var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Large, FullWidth = true }; + var dialog = DialogService.Show($"编辑字典:{dictionary.DisplayName}", parameters, options)!; + var result = await dialog.Result; + if (result is { Canceled: false }) + await LoadDictionaries(); + } + + private async Task ToggleEnabled(DataDictionary dictionary) + { + try + { + await DictionaryService.SetEnabledAsync(dictionary.Id, !dictionary.IsEnabled); + await LoadDictionaries(); + Snackbar.Add($"已{(!dictionary.IsEnabled ? "启用" : "停用")}字典 {dictionary.DisplayName}", Severity.Success); + } + catch (Exception ex) + { + Snackbar.Add($"操作失败:{ex.Message}", Severity.Error); + } + } + + private async Task ResetQueue(DataDictionary dictionary) + { + try + { + await DictionaryService.ResetQueueAsync(dictionary.Id); + await LoadDictionaries(); + Snackbar.Add($"已重置字典 {dictionary.DisplayName} 的轮询游标", Severity.Success); + } + catch (Exception ex) + { + Snackbar.Add($"重置失败:{ex.Message}", Severity.Error); + } + } + + private async Task DeleteDictionary(DataDictionary dictionary) + { + bool? confirm = await DialogService.ShowMessageBox( + "确认删除", + $"确定要删除字典 {dictionary.DisplayName} 吗?删除后将同步清理关联图片文件。", + yesText: "删除", cancelText: "取消"); + + if (confirm != true) + return; + + try + { + await DictionaryService.DeleteAsync(dictionary.Id); + await LoadDictionaries(); + Snackbar.Add($"字典 {dictionary.DisplayName} 已删除", Severity.Success); + } + catch (Exception ex) + { + Snackbar.Add($"删除失败:{ex.Message}", Severity.Error); + } + } + + private static bool IsImageDictionary(DataDictionary dictionary) => + string.Equals(dictionary.Type, DataDictionaryTypes.Image, StringComparison.OrdinalIgnoreCase); + + private static bool IsQueueMode(DataDictionary dictionary) => + string.Equals(dictionary.ReadMode, DataDictionaryReadModes.Queue, StringComparison.OrdinalIgnoreCase); +} diff --git a/src/TelegramPanel.Web/Components/Pages/GroupCreate.razor b/src/TelegramPanel.Web/Components/Pages/GroupCreate.razor index e84c1ba..06d3513 100644 --- a/src/TelegramPanel.Web/Components/Pages/GroupCreate.razor +++ b/src/TelegramPanel.Web/Components/Pages/GroupCreate.razor @@ -1,4 +1,4 @@ -@page "/groups/create" +@page "/groups/create" @inject IGroupService GroupService @inject AccountManagementService AccountManagement @inject TelegramPanel.Core.Services.AccountRiskService RiskService @@ -180,7 +180,8 @@ CreatorAccountId = selectedAccountId, CategoryId = selectedCategoryId > 0 ? selectedCategoryId : null, CreatedAt = groupInfo.CreatedAt ?? now, - SyncedAt = now + SyncedAt = now, + SystemCreatedAtUtc = now }; var savedGroup = await GroupManagement.CreateOrUpdateGroupAsync(group); @@ -222,3 +223,5 @@ return namePart; } } + + diff --git a/src/TelegramPanel.Web/Components/Pages/Tasks.razor b/src/TelegramPanel.Web/Components/Pages/Tasks.razor index bb13b36..4260f0c 100644 --- a/src/TelegramPanel.Web/Components/Pages/Tasks.razor +++ b/src/TelegramPanel.Web/Components/Pages/Tasks.razor @@ -1,5 +1,7 @@ @page "/tasks" @inject BatchTaskManagementService TaskManagement +@inject ScheduledTaskService ScheduledTaskManagement +@inject ImageAssetStorageService AssetStorage @inject TelegramPanel.Web.Modules.ModuleContributionRegistry Contributions @inject IEnumerable TaskRerunBuilders @inject PanelTimeZoneService TimeZone @@ -48,6 +50,83 @@ + + + + + 计划任务 (@scheduledTasks.Count) + + + ID + 归属 + 任务类型 + 状态 + Cron + 下次运行 + 上次运行 + 操作 + + + @context.Id + + @GetCategoryName(context.TaskType) + + + @GetTaskTypeName(context.TaskType) + + + @GetScheduledStatusName(context.Status) + + + @context.CronExpression + 时区:@TimeZone.Current.Id + + + @if (context.NextRunAtUtc.HasValue) + { + + } + else + { + - + } + + + @if (context.LastRunAtUtc.HasValue) + { + + } + else + { + - + } + + + + + @if (string.Equals(context.Status, ScheduledTaskStatuses.Enabled, StringComparison.OrdinalIgnoreCase)) + { + + } + else + { + + } + + + + + 暂无计划任务 + + + + + @@ -192,6 +271,7 @@ @code { private List tasks = new(); + private List scheduledTasks = new(); private bool loading = true; private string historyStatusFilter = "all"; private string filterCategory = "all"; @@ -232,8 +312,11 @@ { while (await refreshTimer.WaitForNextTickAsync(refreshCts.Token)) { - if (tasks.Any(t => t.Status is "pending" or "running")) + if (tasks.Any(t => t.Status is "pending" or "running") + || scheduledTasks.Any(t => string.Equals(t.Status, ScheduledTaskStatuses.Enabled, StringComparison.OrdinalIgnoreCase))) + { await InvokeAsync(LoadTasks); + } } } catch (OperationCanceledException) @@ -248,8 +331,11 @@ loading = true; try { - var taskList = await TaskManagement.GetRecentTasksAsync(50); + var taskList = await TaskManagement.GetRecentTasksAsync(100); tasks = taskList.OrderByDescending(t => t.CreatedAt).ToList(); + scheduledTasks = (await ScheduledTaskManagement.GetAllAsync()) + .OrderByDescending(x => x.CreatedAt) + .ToList(); } catch (Exception ex) { @@ -407,6 +493,16 @@ private static bool IsHistoryStatus(string status) => status is "completed" or "failed"; + private static string GetScheduledStatusName(string status) => + string.Equals((status ?? string.Empty).Trim(), ScheduledTaskStatuses.Paused, StringComparison.OrdinalIgnoreCase) + ? "已暂停" + : "启用中"; + + private static Color GetScheduledStatusColor(string status) => + string.Equals((status ?? string.Empty).Trim(), ScheduledTaskStatuses.Paused, StringComparison.OrdinalIgnoreCase) + ? Color.Warning + : Color.Success; + private void ShowTaskDetails(BatchTask task) { var createdAt = TimeZone.Format(task.CreatedAt, "yyyy-MM-dd HH:mm"); @@ -811,6 +907,7 @@ { try { + await CleanupBatchTaskAssetsAsync(task); await TaskManagement.DeleteTaskAsync(task.Id); await LoadTasks(); Snackbar.Add(isActive ? "任务已删除,后台执行将在下一次状态检查时停止" : "任务已删除", Severity.Success); @@ -861,7 +958,10 @@ } foreach (var item in historyTaskList) + { + await CleanupBatchTaskAssetsAsync(item); await TaskManagement.DeleteTaskAsync(item.Id); + } Snackbar.Add($"已清理 {historyTaskList.Count} 条历史任务记录", Severity.Success); } @@ -871,7 +971,10 @@ await TaskManagement.CompleteTaskAsync(activeTask.Id, success: false); foreach (var item in allTasks) + { + await CleanupBatchTaskAssetsAsync(item); await TaskManagement.DeleteTaskAsync(item.Id); + } tasks.Clear(); Snackbar.Add($"已清理 {allTasks.Count} 条任务记录", Severity.Success); @@ -885,6 +988,114 @@ } } + private async Task CleanupBatchTaskAssetsAsync(BatchTask task) + { + var scopeId = TaskAssetScopeHelper.GetAssetScopeId(task.Config); + if (!string.IsNullOrWhiteSpace(scopeId)) + await AssetStorage.DeleteScopeAsync(scopeId); + } + + private void ShowScheduledTaskDetails(ScheduledTask task) + { + var details = $"任务类型: {GetTaskTypeName(task.TaskType)}\n" + + $"状态: {GetScheduledStatusName(task.Status)}\n" + + $"Cron: {task.CronExpression}\n" + + $"时区: {TimeZone.Current.Id}\n" + + $"总数: {task.Total}\n" + + $"创建时间: {TimeZone.Format(task.CreatedAt, "yyyy-MM-dd HH:mm")}\n" + + $"更新时间: {TimeZone.Format(task.UpdatedAt, "yyyy-MM-dd HH:mm")}\n"; + + if (task.NextRunAtUtc.HasValue) + details += $"下次运行: {TimeZone.Format(task.NextRunAtUtc.Value, "yyyy-MM-dd HH:mm")}\n"; + if (task.LastRunAtUtc.HasValue) + details += $"上次运行: {TimeZone.Format(task.LastRunAtUtc.Value, "yyyy-MM-dd HH:mm")}\n"; + if (task.LastBatchTaskId.HasValue) + details += $"最近批次任务: #{task.LastBatchTaskId.Value}\n"; + if (!string.IsNullOrWhiteSpace(task.ConfigJson)) + details += $"\n配置信息:\n{task.ConfigJson}"; + + var html = System.Net.WebUtility.HtmlEncode(details) + .Replace("\r\n", "\n", StringComparison.Ordinal) + .Replace("\n", "
", StringComparison.Ordinal); + DialogService.ShowMessageBox("计划任务详情", (MarkupString)html); + } + + private async Task OpenEditScheduledTask(ScheduledTask task) + { + var parameters = new DialogParameters + { + ["TaskId"] = task.Id + }; + var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Large, FullWidth = true }; + var dialog = DialogService.Show($"编辑计划任务 #{task.Id}", parameters, options)!; + var result = await dialog.Result; + if (result is { Canceled: false }) + await LoadTasks(); + } + + private async Task PauseScheduledTask(ScheduledTask task) + { + if (string.Equals(task.Status, ScheduledTaskStatuses.Paused, StringComparison.OrdinalIgnoreCase)) + return; + + bool? result = await DialogService.ShowMessageBox( + "确认暂停", + $"确定要暂停计划任务 #{task.Id} 吗?", + yesText: "暂停", cancelText: "返回"); + + if (result != true) + return; + + try + { + await ScheduledTaskManagement.PauseAsync(task.Id); + await LoadTasks(); + Snackbar.Add("计划任务已暂停", Severity.Success); + } + catch (Exception ex) + { + Snackbar.Add($"暂停失败:{ex.Message}", Severity.Error); + } + } + + private async Task ResumeScheduledTask(ScheduledTask task) + { + if (string.Equals(task.Status, ScheduledTaskStatuses.Enabled, StringComparison.OrdinalIgnoreCase)) + return; + + try + { + await ScheduledTaskManagement.ResumeAsync(task.Id); + await LoadTasks(); + Snackbar.Add("计划任务已恢复", Severity.Success); + } + catch (Exception ex) + { + Snackbar.Add($"恢复失败:{ex.Message}", Severity.Error); + } + } + + private async Task DeleteScheduledTask(ScheduledTask task) + { + bool? result = await DialogService.ShowMessageBox( + "确认删除", + $"确定要删除计划任务 #{task.Id} 吗?删除后不会影响已经触发的执行记录。", + yesText: "删除", cancelText: "取消"); + + if (result != true) + return; + + try + { + await ScheduledTaskManagement.DeleteAsync(task.Id); + await LoadTasks(); + Snackbar.Add("计划任务已删除", Severity.Success); + } + catch (Exception ex) + { + Snackbar.Add($"删除失败:{ex.Message}", Severity.Error); + } + } private async Task OpenCreateTaskDialog() { var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Large, FullWidth = true }; @@ -1039,3 +1250,4 @@ + diff --git a/src/TelegramPanel.Web/Modules/BuiltIn/TaskCatalogModule.cs b/src/TelegramPanel.Web/Modules/BuiltIn/TaskCatalogModule.cs index 077c040..2d7519e 100644 --- a/src/TelegramPanel.Web/Modules/BuiltIn/TaskCatalogModule.cs +++ b/src/TelegramPanel.Web/Modules/BuiltIn/TaskCatalogModule.cs @@ -54,6 +54,46 @@ public sealed class TaskCatalogModule : ITelegramPanelModule, IModuleTaskProvide Order = 120 }; + yield return new ModuleTaskDefinition + { + Category = "user", + TaskType = BatchTaskTypes.ChannelGroupPrivateCreate, + DisplayName = "自动创建私密频道/群组", + Description = "按账号分类批量创建私密频道或群组,支持标题变量、固定头像/图片字典头像、数量上限与延时抖动。", + Icon = Icons.Material.Filled.AddCircle, + EditorComponentType = typeof(TelegramPanel.Web.Components.Dialogs.ChannelGroupPrivateCreateTaskEditor).AssemblyQualifiedName ?? "", + TaskCenter = new ModuleTaskCenterCapabilities + { + CanPause = true, + CanResume = true, + CanEdit = true, + CanRerun = true, + EditComponentType = typeof(TelegramPanel.Web.Components.Dialogs.ChannelGroupPrivateCreateTaskEditor).AssemblyQualifiedName ?? "", + AutoPauseBeforeEdit = true + }, + Order = 130 + }; + + yield return new ModuleTaskDefinition + { + Category = "user", + TaskType = BatchTaskTypes.ChannelGroupPublicize, + DisplayName = "私密频道/群组公开化", + Description = "从系统创建的私密频道/群组中按分类挑选候选对象,批量设置标题、描述、用户名与头像后公开。", + Icon = Icons.Material.Filled.Public, + EditorComponentType = typeof(TelegramPanel.Web.Components.Dialogs.ChannelGroupPublicizeTaskEditor).AssemblyQualifiedName ?? "", + TaskCenter = new ModuleTaskCenterCapabilities + { + CanPause = true, + CanResume = true, + CanEdit = true, + CanRerun = true, + EditComponentType = typeof(TelegramPanel.Web.Components.Dialogs.ChannelGroupPublicizeTaskEditor).AssemblyQualifiedName ?? "", + AutoPauseBeforeEdit = true + }, + Order = 140 + }; + yield return new ModuleTaskDefinition { Category = "system", diff --git a/src/TelegramPanel.Web/Program.cs b/src/TelegramPanel.Web/Program.cs index 8559b0c..983267e 100644 --- a/src/TelegramPanel.Web/Program.cs +++ b/src/TelegramPanel.Web/Program.cs @@ -409,6 +409,11 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.Configure(builder.Configuration.GetSection("UpdateCheck")); builder.Services.Configure(builder.Configuration.GetSection("SelfUpdate")); builder.Services.AddSingleton(); @@ -418,7 +423,10 @@ builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddHostedService(); +builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); diff --git a/src/TelegramPanel.Web/Services/ChannelGroupAutomationTaskModels.cs b/src/TelegramPanel.Web/Services/ChannelGroupAutomationTaskModels.cs new file mode 100644 index 0000000..d7fb197 --- /dev/null +++ b/src/TelegramPanel.Web/Services/ChannelGroupAutomationTaskModels.cs @@ -0,0 +1,133 @@ +using System.Text.Json.Serialization; + +namespace TelegramPanel.Web.Services; + +public static class ChannelGroupAutomationTaskObjectTypes +{ + public const string Channel = "channel"; + public const string Group = "group"; +} + +public static class ChannelGroupAutomationAvatarSourceModes +{ + public const string None = "none"; + public const string Fixed = "fixed"; + public const string Dictionary = "dictionary"; +} + +public sealed class ChannelGroupPrivateCreateTaskConfig +{ + [JsonPropertyName("category_ids")] + public List CategoryIds { get; set; } = new(); + + [JsonPropertyName("category_names")] + public List CategoryNames { get; set; } = new(); + + [JsonPropertyName("create_type")] + public string CreateType { get; set; } = ChannelGroupAutomationTaskObjectTypes.Channel; + + [JsonPropertyName("channel_group_id")] + public int? ChannelGroupId { get; set; } + + [JsonPropertyName("channel_group_name")] + public string? ChannelGroupName { get; set; } + + [JsonPropertyName("group_category_id")] + public int? GroupCategoryId { get; set; } + + [JsonPropertyName("group_category_name")] + public string? GroupCategoryName { get; set; } + + [JsonPropertyName("system_created_limit")] + public int SystemCreatedLimit { get; set; } = 10; + + [JsonPropertyName("per_account_batch_size")] + public int PerAccountBatchSize { get; set; } = 1; + + [JsonPropertyName("min_delay_seconds")] + public int MinDelaySeconds { get; set; } = 10; + + [JsonPropertyName("max_delay_seconds")] + public int MaxDelaySeconds { get; set; } = 30; + + [JsonPropertyName("jitter_percent")] + public int JitterPercent { get; set; } = 20; + + [JsonPropertyName("title_template")] + public string TitleTemplate { get; set; } = string.Empty; + + [JsonPropertyName("avatar_source")] + public string AvatarSource { get; set; } = ChannelGroupAutomationAvatarSourceModes.None; + + [JsonPropertyName("fixed_avatar_asset_path")] + public string? FixedAvatarAssetPath { get; set; } + + [JsonPropertyName("avatar_dictionary_token")] + public string? AvatarDictionaryToken { get; set; } + + [JsonPropertyName("asset_scope_id")] + public string? AssetScopeId { get; set; } +} + +public sealed class ChannelGroupPublicizeTaskConfig +{ + [JsonPropertyName("category_ids")] + public List CategoryIds { get; set; } = new(); + + [JsonPropertyName("category_names")] + public List CategoryNames { get; set; } = new(); + + [JsonPropertyName("target_type")] + public string TargetType { get; set; } = ChannelGroupAutomationTaskObjectTypes.Channel; + + [JsonPropertyName("channel_group_id")] + public int? ChannelGroupId { get; set; } + + [JsonPropertyName("channel_group_name")] + public string? ChannelGroupName { get; set; } + + [JsonPropertyName("group_category_id")] + public int? GroupCategoryId { get; set; } + + [JsonPropertyName("group_category_name")] + public string? GroupCategoryName { get; set; } + + [JsonPropertyName("min_system_created_days")] + public int MinSystemCreatedDays { get; set; } + + [JsonPropertyName("max_public_count")] + public int MaxPublicCount { get; set; } = 10; + + [JsonPropertyName("per_account_batch_size")] + public int PerAccountBatchSize { get; set; } = 1; + + [JsonPropertyName("min_delay_seconds")] + public int MinDelaySeconds { get; set; } = 10; + + [JsonPropertyName("max_delay_seconds")] + public int MaxDelaySeconds { get; set; } = 30; + + [JsonPropertyName("jitter_percent")] + public int JitterPercent { get; set; } = 20; + + [JsonPropertyName("title_template")] + public string TitleTemplate { get; set; } = string.Empty; + + [JsonPropertyName("description_template")] + public string DescriptionTemplate { get; set; } = string.Empty; + + [JsonPropertyName("username_template")] + public string UsernameTemplate { get; set; } = string.Empty; + + [JsonPropertyName("avatar_source")] + public string AvatarSource { get; set; } = ChannelGroupAutomationAvatarSourceModes.None; + + [JsonPropertyName("fixed_avatar_asset_path")] + public string? FixedAvatarAssetPath { get; set; } + + [JsonPropertyName("avatar_dictionary_token")] + public string? AvatarDictionaryToken { get; set; } + + [JsonPropertyName("asset_scope_id")] + public string? AssetScopeId { get; set; } +} diff --git a/src/TelegramPanel.Web/Services/ChannelGroupPrivateCreateTaskHandler.cs b/src/TelegramPanel.Web/Services/ChannelGroupPrivateCreateTaskHandler.cs new file mode 100644 index 0000000..349eade --- /dev/null +++ b/src/TelegramPanel.Web/Services/ChannelGroupPrivateCreateTaskHandler.cs @@ -0,0 +1,229 @@ +using System.Text.Json; +using TelegramPanel.Core.BatchTasks; +using TelegramPanel.Core.Interfaces; +using TelegramPanel.Core.Services; +using TelegramPanel.Data.Entities; +using TelegramPanel.Modules; + +namespace TelegramPanel.Web.Services; + +/// +/// 自动创建私密频道/群组任务处理器。 +/// +public sealed class ChannelGroupPrivateCreateTaskHandler : IModuleTaskHandler +{ + public string TaskType => BatchTaskTypes.ChannelGroupPrivateCreate; + + public async Task ExecuteAsync(IModuleTaskExecutionHost host, CancellationToken cancellationToken) + { + var config = Deserialize(host.Config); + Validate(config); + + var accountManagement = host.Services.GetRequiredService(); + var channelManagement = host.Services.GetRequiredService(); + var groupManagement = host.Services.GetRequiredService(); + var channelService = host.Services.GetRequiredService(); + var groupService = host.Services.GetRequiredService(); + var taskManagement = host.Services.GetRequiredService(); + var templateRendering = host.Services.GetRequiredService(); + var assetStorage = host.Services.GetRequiredService(); + + var selectedCategoryIds = config.CategoryIds.Where(x => x > 0).ToHashSet(); + var accounts = (await accountManagement.GetActiveAccountsAsync()) + .Where(x => x.UserId > 0) + .Where(x => x.CategoryId.HasValue && selectedCategoryIds.Contains(x.CategoryId.Value)) + .Where(x => x.Category?.ExcludeFromOperations != true) + .OrderBy(x => x.Id) + .ToList(); + if (accounts.Count == 0) + throw new InvalidOperationException("所选账号分类下没有可用账号"); + + var created = 0; + var failed = 0; + var systemCreatedCountByAccount = string.Equals(config.CreateType, ChannelGroupAutomationTaskObjectTypes.Channel, StringComparison.OrdinalIgnoreCase) + ? (await channelManagement.GetAllChannelsAsync()) + .Where(x => x.CreatorAccountId.HasValue && x.SystemCreatedAtUtc.HasValue) + .GroupBy(x => x.CreatorAccountId!.Value) + .ToDictionary(x => x.Key, x => x.Count()) + : (await groupManagement.GetAllGroupsAsync()) + .Where(x => x.CreatorAccountId.HasValue && x.SystemCreatedAtUtc.HasValue) + .GroupBy(x => x.CreatorAccountId!.Value) + .ToDictionary(x => x.Key, x => x.Count()); + + foreach (var account in accounts) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!await host.IsStillRunningAsync(cancellationToken)) + return; + + var createdCount = systemCreatedCountByAccount.TryGetValue(account.Id, out var current) ? current : 0; + var processedForAccount = 0; + while (processedForAccount < config.PerAccountBatchSize && createdCount < config.SystemCreatedLimit) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!await host.IsStillRunningAsync(cancellationToken)) + return; + + try + { + var title = (await templateRendering.RenderTextTemplateAsync(config.TitleTemplate, cancellationToken)).Trim(); + if (title.Length == 0) + throw new InvalidOperationException("标题模板解析结果为空"); + + if (string.Equals(config.CreateType, ChannelGroupAutomationTaskObjectTypes.Channel, StringComparison.OrdinalIgnoreCase)) + { + var info = await channelService.CreateChannelAsync(account.Id, title, string.Empty, isPublic: false); + var now = DateTime.UtcNow; + var channel = new Channel + { + TelegramId = info.TelegramId, + AccessHash = info.AccessHash, + Title = title, + Username = null, + IsBroadcast = true, + MemberCount = info.MemberCount, + About = string.Empty, + CreatorAccountId = account.Id, + GroupId = config.ChannelGroupId, + CreatedAt = info.CreatedAt ?? now, + SystemCreatedAtUtc = now, + SyncedAt = now + }; + var saved = await channelManagement.CreateOrUpdateChannelAsync(channel); + await channelManagement.UpsertAccountChannelAsync(account.Id, saved.Id, true, true, now); + await ApplyAvatarIfNeededAsync(config, saved.TelegramId, isChannel: true, account.Id, templateRendering, assetStorage, channelService, groupService, cancellationToken); + } + else + { + var info = await groupService.CreateGroupAsync(account.Id, title, string.Empty, isPublic: false, username: null); + var now = DateTime.UtcNow; + var group = new Group + { + TelegramId = info.TelegramId, + AccessHash = info.AccessHash, + Title = title, + Username = null, + MemberCount = Math.Max(info.MemberCount, 1), + About = string.Empty, + CreatorAccountId = account.Id, + CategoryId = config.GroupCategoryId, + CreatedAt = info.CreatedAt ?? now, + SystemCreatedAtUtc = now, + SyncedAt = now + }; + var saved = await groupManagement.CreateOrUpdateGroupAsync(group); + await groupManagement.UpsertAccountGroupAsync(account.Id, saved.Id, true, true, now); + await ApplyAvatarIfNeededAsync(config, saved.TelegramId, isChannel: false, account.Id, templateRendering, assetStorage, channelService, groupService, cancellationToken); + } + + processedForAccount++; + createdCount++; + systemCreatedCountByAccount[account.Id] = createdCount; + created++; + await host.UpdateProgressAsync(created, failed, cancellationToken); + } + catch + { + processedForAccount++; + created++; + failed++; + await host.UpdateProgressAsync(created, failed, cancellationToken); + } + + if (processedForAccount < config.PerAccountBatchSize && createdCount < config.SystemCreatedLimit) + { + var continued = await ChannelGroupTaskDelayHelper.DelayAsync( + host, + config.MinDelaySeconds, + config.MaxDelaySeconds, + config.JitterPercent, + cancellationToken); + if (!continued) + return; + } + } + } + + await taskManagement.UpdateTaskDraftAsync(host.TaskId, created, host.Config); + } + + private static async Task ApplyAvatarIfNeededAsync( + ChannelGroupPrivateCreateTaskConfig config, + long telegramId, + bool isChannel, + int accountId, + TemplateRenderingService templateRendering, + ImageAssetStorageService assetStorage, + IChannelService channelService, + IGroupService groupService, + CancellationToken cancellationToken) + { + if (string.Equals(config.AvatarSource, ChannelGroupAutomationAvatarSourceModes.None, StringComparison.OrdinalIgnoreCase)) + return; + + StoredImageAssetInfo? asset = null; + if (string.Equals(config.AvatarSource, ChannelGroupAutomationAvatarSourceModes.Fixed, StringComparison.OrdinalIgnoreCase)) + { + var path = (config.FixedAvatarAssetPath ?? string.Empty).Trim(); + if (path.Length == 0) + throw new InvalidOperationException("固定头像路径为空"); + asset = new StoredImageAssetInfo(path, Path.GetFileName(path)); + } + else if (string.Equals(config.AvatarSource, ChannelGroupAutomationAvatarSourceModes.Dictionary, StringComparison.OrdinalIgnoreCase)) + { + asset = await templateRendering.ResolveImageTemplateAsync(config.AvatarDictionaryToken ?? string.Empty, cancellationToken); + } + + if (asset == null) + return; + + await using var stream = await assetStorage.OpenReadAsync(asset.AssetPath, cancellationToken); + if (isChannel) + await channelService.SetChannelPhotoAsync(accountId, telegramId, stream, asset.FileName, cancellationToken); + else + await groupService.SetGroupPhotoAsync(accountId, telegramId, stream, asset.FileName, cancellationToken); + } + + private static ChannelGroupPrivateCreateTaskConfig Deserialize(string? config) + { + if (string.IsNullOrWhiteSpace(config)) + throw new InvalidOperationException("任务配置为空"); + + return JsonSerializer.Deserialize(config) + ?? throw new InvalidOperationException("任务配置解析失败"); + } + + private static void Validate(ChannelGroupPrivateCreateTaskConfig config) + { + config.CategoryIds = (config.CategoryIds ?? new List()) + .Where(x => x > 0) + .Distinct() + .ToList(); + if (config.CategoryIds.Count == 0) + throw new InvalidOperationException("请至少选择一个账号分类"); + + config.CreateType = string.Equals(config.CreateType, ChannelGroupAutomationTaskObjectTypes.Group, StringComparison.OrdinalIgnoreCase) + ? ChannelGroupAutomationTaskObjectTypes.Group + : ChannelGroupAutomationTaskObjectTypes.Channel; + + config.TitleTemplate = (config.TitleTemplate ?? string.Empty).Trim(); + if (config.TitleTemplate.Length == 0) + throw new InvalidOperationException("标题模板不能为空"); + + config.SystemCreatedLimit = Math.Max(1, config.SystemCreatedLimit); + config.PerAccountBatchSize = Math.Max(1, config.PerAccountBatchSize); + config.MinDelaySeconds = ChannelGroupTaskDelayHelper.NormalizeMinDelay(config.MinDelaySeconds); + config.MaxDelaySeconds = ChannelGroupTaskDelayHelper.NormalizeMaxDelay(config.MinDelaySeconds, config.MaxDelaySeconds); + config.JitterPercent = ChannelGroupTaskDelayHelper.NormalizeJitterPercent(config.JitterPercent); + config.AvatarSource = NormalizeAvatarSource(config.AvatarSource); + } + + private static string NormalizeAvatarSource(string? value) + { + return string.Equals((value ?? string.Empty).Trim(), ChannelGroupAutomationAvatarSourceModes.Fixed, StringComparison.OrdinalIgnoreCase) + ? ChannelGroupAutomationAvatarSourceModes.Fixed + : string.Equals((value ?? string.Empty).Trim(), ChannelGroupAutomationAvatarSourceModes.Dictionary, StringComparison.OrdinalIgnoreCase) + ? ChannelGroupAutomationAvatarSourceModes.Dictionary + : ChannelGroupAutomationAvatarSourceModes.None; + } +} diff --git a/src/TelegramPanel.Web/Services/ChannelGroupPublicizeTaskHandler.cs b/src/TelegramPanel.Web/Services/ChannelGroupPublicizeTaskHandler.cs new file mode 100644 index 0000000..5248f90 --- /dev/null +++ b/src/TelegramPanel.Web/Services/ChannelGroupPublicizeTaskHandler.cs @@ -0,0 +1,288 @@ +using System.Text.Json; +using System.Text.RegularExpressions; +using TelegramPanel.Core.BatchTasks; +using TelegramPanel.Core.Interfaces; +using TelegramPanel.Core.Services; +using TelegramPanel.Data.Entities; +using TelegramPanel.Modules; + +namespace TelegramPanel.Web.Services; + +/// +/// 将私密频道/群组公开化任务处理器。 +/// +public sealed class ChannelGroupPublicizeTaskHandler : IModuleTaskHandler +{ + private static readonly Regex UsernameRegex = new("^[a-z][a-z0-9_]{4,31}$", RegexOptions.Compiled); + + public string TaskType => BatchTaskTypes.ChannelGroupPublicize; + + public async Task ExecuteAsync(IModuleTaskExecutionHost host, CancellationToken cancellationToken) + { + var config = Deserialize(host.Config); + Validate(config); + + var accountManagement = host.Services.GetRequiredService(); + var channelManagement = host.Services.GetRequiredService(); + var groupManagement = host.Services.GetRequiredService(); + var channelService = host.Services.GetRequiredService(); + var groupService = host.Services.GetRequiredService(); + var taskManagement = host.Services.GetRequiredService(); + var templateRendering = host.Services.GetRequiredService(); + var assetStorage = host.Services.GetRequiredService(); + + var selectedCategoryIds = config.CategoryIds.Where(x => x > 0).ToHashSet(); + var accounts = (await accountManagement.GetActiveAccountsAsync()) + .Where(x => x.UserId > 0) + .Where(x => x.CategoryId.HasValue && selectedCategoryIds.Contains(x.CategoryId.Value)) + .Where(x => x.Category?.ExcludeFromOperations != true) + .OrderBy(x => x.Id) + .ToList(); + if (accounts.Count == 0) + throw new InvalidOperationException("所选账号分类下没有可用账号"); + + var now = DateTime.UtcNow; + var processed = 0; + var failed = 0; + if (string.Equals(config.TargetType, ChannelGroupAutomationTaskObjectTypes.Channel, StringComparison.OrdinalIgnoreCase)) + { + var channels = (await channelManagement.GetAllChannelsAsync()).ToList(); + foreach (var account in accounts) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!await host.IsStillRunningAsync(cancellationToken)) + return; + + var publicCount = channels.Count(x => x.CreatorAccountId == account.Id && !string.IsNullOrWhiteSpace(x.Username)); + var candidates = channels + .Where(x => x.CreatorAccountId == account.Id) + .Where(x => string.IsNullOrWhiteSpace(x.Username)) + .Where(x => x.GroupId == config.ChannelGroupId) + .Where(x => x.SystemCreatedAtUtc.HasValue && x.SystemCreatedAtUtc.Value <= now.AddDays(-config.MinSystemCreatedDays)) + .OrderBy(x => x.SystemCreatedAtUtc) + .ThenBy(x => x.Id) + .ToList(); + + var processedForAccount = 0; + foreach (var channel in candidates) + { + if (processedForAccount >= config.PerAccountBatchSize || publicCount >= config.MaxPublicCount) + break; + + try + { + var title = (await templateRendering.RenderTextTemplateAsync(config.TitleTemplate, cancellationToken)).Trim(); + var about = string.IsNullOrWhiteSpace(config.DescriptionTemplate) + ? channel.About + : (await templateRendering.RenderTextTemplateAsync(config.DescriptionTemplate, cancellationToken)).Trim(); + var username = NormalizeUsername(await templateRendering.RenderTextTemplateAsync(config.UsernameTemplate, cancellationToken)); + ValidateUsername(username); + + await channelService.UpdateChannelInfoAsync(account.Id, channel.TelegramId, title, about); + await ApplyAvatarIfNeededAsync(config, channel.TelegramId, true, account.Id, templateRendering, assetStorage, channelService, groupService, cancellationToken); + await channelService.SetChannelVisibilityAsync(account.Id, channel.TelegramId, true, username); + + channel.Title = title; + channel.About = about; + channel.Username = username; + channel.SyncedAt = DateTime.UtcNow; + await channelManagement.UpdateChannelAsync(channel); + + publicCount++; + processedForAccount++; + processed++; + await host.UpdateProgressAsync(processed, failed, cancellationToken); + } + catch + { + processedForAccount++; + processed++; + failed++; + await host.UpdateProgressAsync(processed, failed, cancellationToken); + } + + if (processedForAccount < config.PerAccountBatchSize && publicCount < config.MaxPublicCount) + { + var continued = await ChannelGroupTaskDelayHelper.DelayAsync( + host, + config.MinDelaySeconds, + config.MaxDelaySeconds, + config.JitterPercent, + cancellationToken); + if (!continued) + return; + } + } + } + } + else + { + var groups = (await groupManagement.GetAllGroupsAsync()).ToList(); + foreach (var account in accounts) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!await host.IsStillRunningAsync(cancellationToken)) + return; + + var publicCount = groups.Count(x => x.CreatorAccountId == account.Id && !string.IsNullOrWhiteSpace(x.Username)); + var candidates = groups + .Where(x => x.CreatorAccountId == account.Id) + .Where(x => string.IsNullOrWhiteSpace(x.Username)) + .Where(x => x.CategoryId == config.GroupCategoryId) + .Where(x => x.SystemCreatedAtUtc.HasValue && x.SystemCreatedAtUtc.Value <= now.AddDays(-config.MinSystemCreatedDays)) + .OrderBy(x => x.SystemCreatedAtUtc) + .ThenBy(x => x.Id) + .ToList(); + + var processedForAccount = 0; + foreach (var group in candidates) + { + if (processedForAccount >= config.PerAccountBatchSize || publicCount >= config.MaxPublicCount) + break; + + try + { + var title = (await templateRendering.RenderTextTemplateAsync(config.TitleTemplate, cancellationToken)).Trim(); + var about = string.IsNullOrWhiteSpace(config.DescriptionTemplate) + ? group.About + : (await templateRendering.RenderTextTemplateAsync(config.DescriptionTemplate, cancellationToken)).Trim(); + var username = NormalizeUsername(await templateRendering.RenderTextTemplateAsync(config.UsernameTemplate, cancellationToken)); + ValidateUsername(username); + + await groupService.UpdateGroupInfoAsync(account.Id, group.TelegramId, title, about); + await ApplyAvatarIfNeededAsync(config, group.TelegramId, false, account.Id, templateRendering, assetStorage, channelService, groupService, cancellationToken); + await groupService.SetGroupVisibilityAsync(account.Id, group.TelegramId, true, username); + + group.Title = title; + group.About = about; + group.Username = username; + group.SyncedAt = DateTime.UtcNow; + await groupManagement.UpdateGroupAsync(group); + + publicCount++; + processedForAccount++; + processed++; + await host.UpdateProgressAsync(processed, failed, cancellationToken); + } + catch + { + processedForAccount++; + processed++; + failed++; + await host.UpdateProgressAsync(processed, failed, cancellationToken); + } + + if (processedForAccount < config.PerAccountBatchSize && publicCount < config.MaxPublicCount) + { + var continued = await ChannelGroupTaskDelayHelper.DelayAsync( + host, + config.MinDelaySeconds, + config.MaxDelaySeconds, + config.JitterPercent, + cancellationToken); + if (!continued) + return; + } + } + } + } + + await taskManagement.UpdateTaskDraftAsync(host.TaskId, processed, host.Config); + } + + private static async Task ApplyAvatarIfNeededAsync( + ChannelGroupPublicizeTaskConfig config, + long telegramId, + bool isChannel, + int accountId, + TemplateRenderingService templateRendering, + ImageAssetStorageService assetStorage, + IChannelService channelService, + IGroupService groupService, + CancellationToken cancellationToken) + { + if (string.Equals(config.AvatarSource, ChannelGroupAutomationAvatarSourceModes.None, StringComparison.OrdinalIgnoreCase)) + return; + + StoredImageAssetInfo? asset = null; + if (string.Equals(config.AvatarSource, ChannelGroupAutomationAvatarSourceModes.Fixed, StringComparison.OrdinalIgnoreCase)) + { + var path = (config.FixedAvatarAssetPath ?? string.Empty).Trim(); + if (path.Length == 0) + throw new InvalidOperationException("固定头像路径为空"); + asset = new StoredImageAssetInfo(path, Path.GetFileName(path)); + } + else if (string.Equals(config.AvatarSource, ChannelGroupAutomationAvatarSourceModes.Dictionary, StringComparison.OrdinalIgnoreCase)) + { + asset = await templateRendering.ResolveImageTemplateAsync(config.AvatarDictionaryToken ?? string.Empty, cancellationToken); + } + + if (asset == null) + return; + + await using var stream = await assetStorage.OpenReadAsync(asset.AssetPath, cancellationToken); + if (isChannel) + await channelService.SetChannelPhotoAsync(accountId, telegramId, stream, asset.FileName, cancellationToken); + else + await groupService.SetGroupPhotoAsync(accountId, telegramId, stream, asset.FileName, cancellationToken); + } + + private static ChannelGroupPublicizeTaskConfig Deserialize(string? config) + { + if (string.IsNullOrWhiteSpace(config)) + throw new InvalidOperationException("任务配置为空"); + + return JsonSerializer.Deserialize(config) + ?? throw new InvalidOperationException("任务配置解析失败"); + } + + private static void Validate(ChannelGroupPublicizeTaskConfig config) + { + config.CategoryIds = (config.CategoryIds ?? new List()) + .Where(x => x > 0) + .Distinct() + .ToList(); + if (config.CategoryIds.Count == 0) + throw new InvalidOperationException("请至少选择一个账号分类"); + + config.TargetType = string.Equals(config.TargetType, ChannelGroupAutomationTaskObjectTypes.Group, StringComparison.OrdinalIgnoreCase) + ? ChannelGroupAutomationTaskObjectTypes.Group + : ChannelGroupAutomationTaskObjectTypes.Channel; + + config.TitleTemplate = (config.TitleTemplate ?? string.Empty).Trim(); + config.DescriptionTemplate = (config.DescriptionTemplate ?? string.Empty).Trim(); + config.UsernameTemplate = (config.UsernameTemplate ?? string.Empty).Trim(); + if (config.TitleTemplate.Length == 0) + throw new InvalidOperationException("标题模板不能为空"); + if (config.UsernameTemplate.Length == 0) + throw new InvalidOperationException("公开用户名模板不能为空"); + + config.MinSystemCreatedDays = Math.Max(0, config.MinSystemCreatedDays); + config.MaxPublicCount = Math.Max(1, config.MaxPublicCount); + config.PerAccountBatchSize = Math.Max(1, config.PerAccountBatchSize); + config.MinDelaySeconds = ChannelGroupTaskDelayHelper.NormalizeMinDelay(config.MinDelaySeconds); + config.MaxDelaySeconds = ChannelGroupTaskDelayHelper.NormalizeMaxDelay(config.MinDelaySeconds, config.MaxDelaySeconds); + config.JitterPercent = ChannelGroupTaskDelayHelper.NormalizeJitterPercent(config.JitterPercent); + config.AvatarSource = NormalizeAvatarSource(config.AvatarSource); + } + + private static string NormalizeAvatarSource(string? value) + { + return string.Equals((value ?? string.Empty).Trim(), ChannelGroupAutomationAvatarSourceModes.Fixed, StringComparison.OrdinalIgnoreCase) + ? ChannelGroupAutomationAvatarSourceModes.Fixed + : string.Equals((value ?? string.Empty).Trim(), ChannelGroupAutomationAvatarSourceModes.Dictionary, StringComparison.OrdinalIgnoreCase) + ? ChannelGroupAutomationAvatarSourceModes.Dictionary + : ChannelGroupAutomationAvatarSourceModes.None; + } + + private static string NormalizeUsername(string value) + { + return (value ?? string.Empty).Trim().TrimStart('@').ToLowerInvariant(); + } + + private static void ValidateUsername(string username) + { + if (!UsernameRegex.IsMatch(username)) + throw new InvalidOperationException("公开用户名不合法,必须为 5-32 位小写字母/数字/下划线,且以字母开头"); + } +} diff --git a/src/TelegramPanel.Web/Services/ChannelGroupTaskDelayHelper.cs b/src/TelegramPanel.Web/Services/ChannelGroupTaskDelayHelper.cs new file mode 100644 index 0000000..0a03da9 --- /dev/null +++ b/src/TelegramPanel.Web/Services/ChannelGroupTaskDelayHelper.cs @@ -0,0 +1,68 @@ +using TelegramPanel.Modules; + +namespace TelegramPanel.Web.Services; + +/// +/// 频道/群组任务延时辅助工具。 +/// +public static class ChannelGroupTaskDelayHelper +{ + public static int NormalizeMinDelay(int value) => Math.Max(0, value); + + public static int NormalizeMaxDelay(int minValue, int maxValue) + { + minValue = NormalizeMinDelay(minValue); + maxValue = Math.Max(0, maxValue); + return Math.Max(minValue, maxValue); + } + + public static int NormalizeJitterPercent(int value) => Math.Clamp(value, 0, 100); + + public static int ComputeDelayMilliseconds(int minSeconds, int maxSeconds, int jitterPercent) + { + minSeconds = NormalizeMinDelay(minSeconds); + maxSeconds = NormalizeMaxDelay(minSeconds, maxSeconds); + jitterPercent = NormalizeJitterPercent(jitterPercent); + + var baseSeconds = maxSeconds <= minSeconds + ? minSeconds + : Random.Shared.Next(minSeconds, maxSeconds + 1); + + var factor = 1d; + if (jitterPercent > 0) + { + var minFactor = 1d - jitterPercent / 100d; + var maxFactor = 1d + jitterPercent / 100d; + factor = minFactor + Random.Shared.NextDouble() * (maxFactor - minFactor); + } + + var seconds = Math.Max(0, (int)Math.Ceiling(baseSeconds * factor)); + return seconds * 1000; + } + + public static async Task DelayAsync( + IModuleTaskExecutionHost host, + int minSeconds, + int maxSeconds, + int jitterPercent, + CancellationToken cancellationToken) + { + var delayMs = ComputeDelayMilliseconds(minSeconds, maxSeconds, jitterPercent); + 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; + } +} diff --git a/src/TelegramPanel.Web/Services/CronExpressionService.cs b/src/TelegramPanel.Web/Services/CronExpressionService.cs new file mode 100644 index 0000000..364a20a --- /dev/null +++ b/src/TelegramPanel.Web/Services/CronExpressionService.cs @@ -0,0 +1,269 @@ +namespace TelegramPanel.Web.Services; + +/// +/// 轻量 Cron 表达式服务(5 位:分 时 日 月 周)。 +/// +public sealed class CronExpressionService +{ + public DateTime? GetNextOccurrenceUtc(string expression, DateTime fromUtc) + { + var schedule = CronSchedule.Parse(expression); + return schedule.GetNextOccurrenceUtc(fromUtc, TimeZoneInfo.Local); + } + + public bool TryValidate(string expression, out string? error) + { + try + { + _ = CronSchedule.Parse(expression); + error = null; + return true; + } + catch (Exception ex) + { + error = ex.Message; + return false; + } + } + + private sealed class CronSchedule + { + private readonly CronField _minutes; + private readonly CronField _hours; + private readonly CronField _days; + private readonly CronField _months; + private readonly CronField _weekdays; + + private CronSchedule(CronField minutes, CronField hours, CronField days, CronField months, CronField weekdays) + { + _minutes = minutes; + _hours = hours; + _days = days; + _months = months; + _weekdays = weekdays; + } + + public static CronSchedule Parse(string expression) + { + var parts = (expression ?? string.Empty) + .Split(new[] { ' ', '\t', '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (parts.Length != 5) + throw new InvalidOperationException("Cron 表达式必须是 5 段:分 时 日 月 周"); + + return new CronSchedule( + CronField.Parse(parts[0], 0, 59, FieldKind.Minute), + CronField.Parse(parts[1], 0, 23, FieldKind.Hour), + CronField.Parse(parts[2], 1, 31, FieldKind.Day), + CronField.Parse(parts[3], 1, 12, FieldKind.Month), + CronField.Parse(parts[4], 0, 6, FieldKind.Weekday)); + } + + public DateTime? GetNextOccurrenceUtc(DateTime fromUtc, TimeZoneInfo timeZone) + { + var local = TimeZoneInfo.ConvertTimeFromUtc(DateTime.SpecifyKind(fromUtc, DateTimeKind.Utc), timeZone); + var candidate = new DateTime(local.Year, local.Month, local.Day, local.Hour, local.Minute, 0, DateTimeKind.Unspecified) + .AddMinutes(1); + + for (var i = 0; i < 60 * 24 * 366 * 5; i++) + { + if (Matches(candidate)) + { + try + { + return TimeZoneInfo.ConvertTimeToUtc(candidate, timeZone); + } + catch (ArgumentException) + { + // 跳过夏令时无效时间。 + } + } + + candidate = candidate.AddMinutes(1); + } + + return null; + } + + private bool Matches(DateTime candidate) + { + if (!_minutes.Contains(candidate.Minute)) + return false; + if (!_hours.Contains(candidate.Hour)) + return false; + if (!_months.Contains(candidate.Month)) + return false; + + var dayMatch = _days.Contains(candidate.Day); + var weekMatch = _weekdays.Contains((int)candidate.DayOfWeek); + var calendarMatch = _days.IsAny && _weekdays.IsAny + ? true + : _days.IsAny + ? weekMatch + : _weekdays.IsAny + ? dayMatch + : dayMatch || weekMatch; + + return calendarMatch; + } + + private enum FieldKind + { + Minute, + Hour, + Day, + Month, + Weekday + } + + private sealed class CronField + { + private readonly bool[] _allowed; + + private CronField(bool[] allowed, bool isAny) + { + _allowed = allowed; + IsAny = isAny; + } + + public bool IsAny { get; } + + public bool Contains(int value) + { + if (value < 0 || value >= _allowed.Length) + return false; + return _allowed[value]; + } + + public static CronField Parse(string text, int min, int max, FieldKind kind) + { + text = (text ?? string.Empty).Trim(); + if (text.Length == 0) + throw new InvalidOperationException("Cron 字段不能为空"); + + var size = max + 1; + var allowed = new bool[size]; + var isAny = text is "*" or "?"; + if (isAny) + { + for (var value = min; value <= max; value++) + allowed[value] = true; + return new CronField(allowed, true); + } + + foreach (var part in text.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + ApplyPart(part, allowed, min, max, kind); + } + + if (!allowed.Any(x => x)) + throw new InvalidOperationException($"Cron 字段无可用值:{text}"); + + return new CronField(allowed, false); + } + + private static void ApplyPart(string part, bool[] allowed, int min, int max, FieldKind kind) + { + var step = 1; + var rangePart = part; + + var slashIndex = part.IndexOf('/'); + if (slashIndex >= 0) + { + rangePart = part[..slashIndex]; + var stepPart = part[(slashIndex + 1)..]; + if (!int.TryParse(stepPart, out step) || step <= 0) + throw new InvalidOperationException($"Cron 步长无效:{part}"); + } + + int start; + int end; + if (rangePart is "*" or "?") + { + start = min; + end = max; + } + else if (rangePart.Contains('-')) + { + var segments = rangePart.Split('-', 2, StringSplitOptions.TrimEntries); + start = ParseValue(segments[0], kind); + end = ParseValue(segments[1], kind); + } + else + { + start = ParseValue(rangePart, kind); + end = slashIndex >= 0 ? max : start; + } + + if (kind == FieldKind.Weekday) + { + start = NormalizeWeekday(start); + end = NormalizeWeekday(end); + } + + if (start < min || start > max || end < min || end > max) + throw new InvalidOperationException($"Cron 取值超出范围:{part}"); + if (end < start) + throw new InvalidOperationException($"Cron 范围无效:{part}"); + + for (var value = start; value <= end; value += step) + allowed[value] = true; + } + + private static int ParseValue(string raw, FieldKind kind) + { + raw = (raw ?? string.Empty).Trim().ToUpperInvariant(); + if (raw.Length == 0) + throw new InvalidOperationException("Cron 字段取值不能为空"); + + if (kind == FieldKind.Month) + { + var month = raw switch + { + "JAN" => 1, + "FEB" => 2, + "MAR" => 3, + "APR" => 4, + "MAY" => 5, + "JUN" => 6, + "JUL" => 7, + "AUG" => 8, + "SEP" => 9, + "OCT" => 10, + "NOV" => 11, + "DEC" => 12, + _ => (int?)null + }; + if (month.HasValue) + return month.Value; + } + + if (kind == FieldKind.Weekday) + { + var weekday = raw switch + { + "SUN" => 0, + "MON" => 1, + "TUE" => 2, + "WED" => 3, + "THU" => 4, + "FRI" => 5, + "SAT" => 6, + _ => (int?)null + }; + if (weekday.HasValue) + return weekday.Value; + } + + if (!int.TryParse(raw, out var value)) + throw new InvalidOperationException($"Cron 字段取值无效:{raw}"); + + return value; + } + + private static int NormalizeWeekday(int value) + { + return value == 7 ? 0 : value; + } + } + } +} diff --git a/src/TelegramPanel.Web/Services/DataDictionaryService.cs b/src/TelegramPanel.Web/Services/DataDictionaryService.cs new file mode 100644 index 0000000..7ae614b --- /dev/null +++ b/src/TelegramPanel.Web/Services/DataDictionaryService.cs @@ -0,0 +1,341 @@ +using System.Collections.Concurrent; +using TelegramPanel.Data.Entities; +using TelegramPanel.Data.Repositories; + +namespace TelegramPanel.Web.Services; + +public sealed record DataDictionaryTextItemInput(string Value); +public sealed record DataDictionaryImageItemInput(string AssetPath, string FileName); + +/// +/// 数据字典业务服务。 +/// +public sealed class DataDictionaryService +{ + private static readonly ConcurrentDictionary QueueLocks = new(); + + private readonly IDataDictionaryRepository _dictionaryRepository; + private readonly IDataDictionaryItemRepository _itemRepository; + private readonly ImageAssetStorageService _assetStorage; + + public DataDictionaryService( + IDataDictionaryRepository dictionaryRepository, + IDataDictionaryItemRepository itemRepository, + ImageAssetStorageService assetStorage) + { + _dictionaryRepository = dictionaryRepository; + _itemRepository = itemRepository; + _assetStorage = assetStorage; + } + + public async Task> GetAllAsync(CancellationToken cancellationToken = default) + { + return await _dictionaryRepository.GetAllWithItemsAsync(cancellationToken); + } + + public async Task GetAsync(int id, CancellationToken cancellationToken = default) + { + return await _dictionaryRepository.GetWithItemsAsync(id, cancellationToken); + } + + public async Task GetByNameAsync(string name, CancellationToken cancellationToken = default) + { + return await _dictionaryRepository.GetByNameAsync(NormalizeName(name), cancellationToken); + } + + public async Task SaveTextDictionaryAsync( + int? id, + string name, + string displayName, + string? description, + string readMode, + bool isEnabled, + IReadOnlyList items, + CancellationToken cancellationToken = default) + { + name = NormalizeName(name); + displayName = NormalizeDisplayName(displayName); + description = NormalizeNullable(description); + readMode = NormalizeReadMode(readMode); + + if (name.Length == 0) + throw new InvalidOperationException("字典名称不能为空"); + if (displayName.Length == 0) + throw new InvalidOperationException("显示名称不能为空"); + + var values = (items ?? Array.Empty()) + .Select(x => NormalizeNullable(x.Value)) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Select(x => x!) + .ToList(); + + if (values.Count == 0) + throw new InvalidOperationException("文本字典至少需要一条内容"); + + var entity = await LoadOrCreateAsync(id, DataDictionaryTypes.Text, cancellationToken); + entity.Name = name; + entity.DisplayName = displayName; + entity.Description = description; + entity.Type = DataDictionaryTypes.Text; + entity.ReadMode = readMode; + entity.IsEnabled = isEnabled; + entity.UpdatedAt = DateTime.UtcNow; + if (entity.Id == 0) + entity = await _dictionaryRepository.AddAsync(entity); + else + await _dictionaryRepository.UpdateAsync(entity); + + var oldItems = await _itemRepository.GetByDictionaryIdAsync(entity.Id, cancellationToken); + foreach (var item in oldItems) + await _itemRepository.DeleteAsync(item); + + for (var index = 0; index < values.Count; index++) + { + await _itemRepository.AddAsync(new DataDictionaryItem + { + DataDictionaryId = entity.Id, + TextValue = values[index], + SortOrder = index, + IsEnabled = true + }); + } + + return (await _dictionaryRepository.GetWithItemsAsync(entity.Id, cancellationToken))!; + } + + public async Task SaveImageDictionaryAsync( + int? id, + string name, + string displayName, + string? description, + string readMode, + bool isEnabled, + IReadOnlyCollection keepItemIds, + IReadOnlyList newImages, + CancellationToken cancellationToken = default) + { + name = NormalizeName(name); + displayName = NormalizeDisplayName(displayName); + description = NormalizeNullable(description); + readMode = NormalizeReadMode(readMode); + + if (name.Length == 0) + throw new InvalidOperationException("字典名称不能为空"); + if (displayName.Length == 0) + throw new InvalidOperationException("显示名称不能为空"); + + var entity = await LoadOrCreateAsync(id, DataDictionaryTypes.Image, cancellationToken); + entity.Name = name; + entity.DisplayName = displayName; + entity.Description = description; + entity.Type = DataDictionaryTypes.Image; + entity.ReadMode = readMode; + entity.IsEnabled = isEnabled; + entity.UpdatedAt = DateTime.UtcNow; + if (entity.Id == 0) + entity = await _dictionaryRepository.AddAsync(entity); + else + await _dictionaryRepository.UpdateAsync(entity); + + var keepSet = (keepItemIds ?? Array.Empty()).ToHashSet(); + var oldItems = await _itemRepository.GetByDictionaryIdAsync(entity.Id, cancellationToken); + foreach (var item in oldItems.Where(x => !keepSet.Contains(x.Id))) + { + await _assetStorage.DeleteAssetAsync(item.AssetPath, cancellationToken); + await _itemRepository.DeleteAsync(item); + } + + var currentItems = await _itemRepository.GetByDictionaryIdAsync(entity.Id, cancellationToken); + var nextOrder = currentItems.Count == 0 ? 0 : currentItems.Max(x => x.SortOrder) + 1; + foreach (var image in newImages ?? Array.Empty()) + { + await _itemRepository.AddAsync(new DataDictionaryItem + { + DataDictionaryId = entity.Id, + AssetPath = NormalizeNullable(image.AssetPath), + FileName = NormalizeNullable(image.FileName), + SortOrder = nextOrder++, + IsEnabled = true + }); + } + + var finalItems = await _itemRepository.GetByDictionaryIdAsync(entity.Id, cancellationToken); + if (finalItems.Count == 0) + throw new InvalidOperationException("图片字典至少需要一张图片"); + + return (await _dictionaryRepository.GetWithItemsAsync(entity.Id, cancellationToken))!; + } + + public async Task SetEnabledAsync(int id, bool enabled, CancellationToken cancellationToken = default) + { + var entity = await _dictionaryRepository.GetWithItemsAsync(id, cancellationToken) + ?? throw new InvalidOperationException("字典不存在"); + entity.IsEnabled = enabled; + entity.UpdatedAt = DateTime.UtcNow; + await _dictionaryRepository.UpdateAsync(entity); + } + + public async Task ResetQueueAsync(int id, CancellationToken cancellationToken = default) + { + var entity = await _dictionaryRepository.GetWithItemsAsync(id, cancellationToken) + ?? throw new InvalidOperationException("字典不存在"); + entity.NextIndex = 0; + entity.UpdatedAt = DateTime.UtcNow; + await _dictionaryRepository.UpdateAsync(entity); + } + + public async Task DeleteAsync(int id, CancellationToken cancellationToken = default) + { + var entity = await _dictionaryRepository.GetWithItemsAsync(id, cancellationToken); + if (entity == null) + return; + + foreach (var item in entity.Items) + await _assetStorage.DeleteAssetAsync(item.AssetPath, cancellationToken); + + await _assetStorage.DeleteScopeAsync($"dictionaries/{entity.Id}", cancellationToken); + await _dictionaryRepository.DeleteAsync(entity); + } + + public async Task ResolveTextValueAsync(string name, CancellationToken cancellationToken = default) + { + var dictionary = await _dictionaryRepository.GetByNameAsync(NormalizeName(name), cancellationToken) + ?? throw new InvalidOperationException($"字典不存在:{name}"); + + if (!dictionary.IsEnabled) + throw new InvalidOperationException($"字典已停用:{dictionary.Name}"); + if (!string.Equals(dictionary.Type, DataDictionaryTypes.Text, StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException($"字典不是文本类型:{dictionary.Name}"); + + var items = dictionary.Items + .Where(x => x.IsEnabled) + .Select(x => NormalizeNullable(x.TextValue)) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Select(x => x!) + .ToList(); + if (items.Count == 0) + throw new InvalidOperationException($"字典没有可用内容:{dictionary.Name}"); + + if (string.Equals(dictionary.ReadMode, DataDictionaryReadModes.Queue, StringComparison.OrdinalIgnoreCase)) + { + var gate = QueueLocks.GetOrAdd(dictionary.Id, _ => new SemaphoreSlim(1, 1)); + await gate.WaitAsync(cancellationToken); + try + { + dictionary = await _dictionaryRepository.GetByNameAsync(dictionary.Name, cancellationToken) + ?? throw new InvalidOperationException($"字典不存在:{dictionary.Name}"); + items = dictionary.Items + .Where(x => x.IsEnabled) + .Select(x => NormalizeNullable(x.TextValue)) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Select(x => x!) + .ToList(); + if (items.Count == 0) + throw new InvalidOperationException($"字典没有可用内容:{dictionary.Name}"); + + var index = dictionary.NextIndex; + if (index < 0) + index = 0; + var picked = items[index % items.Count]; + dictionary.NextIndex = (index + 1) % items.Count; + dictionary.UpdatedAt = DateTime.UtcNow; + await _dictionaryRepository.UpdateAsync(dictionary); + return picked; + } + finally + { + gate.Release(); + } + } + + return items[Random.Shared.Next(items.Count)]; + } + + public async Task ResolveImageValueAsync(string name, CancellationToken cancellationToken = default) + { + var dictionary = await _dictionaryRepository.GetByNameAsync(NormalizeName(name), cancellationToken) + ?? throw new InvalidOperationException($"字典不存在:{name}"); + + if (!dictionary.IsEnabled) + throw new InvalidOperationException($"字典已停用:{dictionary.Name}"); + if (!string.Equals(dictionary.Type, DataDictionaryTypes.Image, StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException($"字典不是图片类型:{dictionary.Name}"); + + var items = dictionary.Items + .Where(x => x.IsEnabled && !string.IsNullOrWhiteSpace(x.AssetPath)) + .OrderBy(x => x.SortOrder) + .ThenBy(x => x.Id) + .ToList(); + if (items.Count == 0) + throw new InvalidOperationException($"字典没有可用图片:{dictionary.Name}"); + + if (string.Equals(dictionary.ReadMode, DataDictionaryReadModes.Queue, StringComparison.OrdinalIgnoreCase)) + { + var gate = QueueLocks.GetOrAdd(dictionary.Id, _ => new SemaphoreSlim(1, 1)); + await gate.WaitAsync(cancellationToken); + try + { + dictionary = await _dictionaryRepository.GetByNameAsync(dictionary.Name, cancellationToken) + ?? throw new InvalidOperationException($"字典不存在:{dictionary.Name}"); + items = dictionary.Items + .Where(x => x.IsEnabled && !string.IsNullOrWhiteSpace(x.AssetPath)) + .OrderBy(x => x.SortOrder) + .ThenBy(x => x.Id) + .ToList(); + if (items.Count == 0) + throw new InvalidOperationException($"字典没有可用图片:{dictionary.Name}"); + + var index = dictionary.NextIndex; + if (index < 0) + index = 0; + var picked = items[index % items.Count]; + dictionary.NextIndex = (index + 1) % items.Count; + dictionary.UpdatedAt = DateTime.UtcNow; + await _dictionaryRepository.UpdateAsync(dictionary); + return new StoredImageAssetInfo(picked.AssetPath!, picked.FileName ?? "image.jpg"); + } + finally + { + gate.Release(); + } + } + + var randomItem = items[Random.Shared.Next(items.Count)]; + return new StoredImageAssetInfo(randomItem.AssetPath!, randomItem.FileName ?? "image.jpg"); + } + + private async Task LoadOrCreateAsync(int? id, string type, CancellationToken cancellationToken) + { + if (!id.HasValue || id.Value <= 0) + return new DataDictionary { Type = type, ReadMode = DataDictionaryReadModes.Random, IsEnabled = true }; + + var entity = await _dictionaryRepository.GetWithItemsAsync(id.Value, cancellationToken) + ?? throw new InvalidOperationException("字典不存在"); + if (!string.Equals(entity.Type, type, StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException("字典类型不匹配,不能跨类型修改"); + return entity; + } + + private static string NormalizeName(string? value) + { + return (value ?? string.Empty).Trim().ToLowerInvariant(); + } + + private static string NormalizeDisplayName(string? value) + { + return (value ?? string.Empty).Trim(); + } + + private static string NormalizeReadMode(string? value) + { + return string.Equals((value ?? string.Empty).Trim(), DataDictionaryReadModes.Queue, StringComparison.OrdinalIgnoreCase) + ? DataDictionaryReadModes.Queue + : DataDictionaryReadModes.Random; + } + + private static string? NormalizeNullable(string? value) + { + var text = (value ?? string.Empty).Trim(); + return text.Length == 0 ? null : text; + } +} diff --git a/src/TelegramPanel.Web/Services/ImageAssetStorageService.cs b/src/TelegramPanel.Web/Services/ImageAssetStorageService.cs new file mode 100644 index 0000000..f5705a0 --- /dev/null +++ b/src/TelegramPanel.Web/Services/ImageAssetStorageService.cs @@ -0,0 +1,175 @@ +using Microsoft.AspNetCore.Components.Forms; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Logging; +using TelegramPanel.Core.Services.Telegram; + +namespace TelegramPanel.Web.Services; + +public enum ImageAssetKind +{ + General, + Avatar +} + +public sealed record StoredImageAssetInfo(string AssetPath, string FileName) +{ + public string PublicUrl => "/" + AssetPath.Replace('\\', '/'); +} + +/// +/// 图片资产存储服务。 +/// +public sealed class ImageAssetStorageService +{ + private readonly IWebHostEnvironment _environment; + private readonly ILogger _logger; + + public ImageAssetStorageService(IWebHostEnvironment environment, ILogger logger) + { + _environment = environment; + _logger = logger; + } + + public async Task SaveAsync( + Stream fileStream, + string fileName, + string scope, + ImageAssetKind kind, + CancellationToken cancellationToken = default) + { + if (fileStream == null) + throw new ArgumentNullException(nameof(fileStream)); + + scope = NormalizeScope(scope); + if (scope.Length == 0) + throw new ArgumentException("图片作用域不能为空", nameof(scope)); + + fileName = (fileName ?? "image.jpg").Trim(); + if (fileName.Length == 0) + fileName = "image.jpg"; + + var extension = ".jpg"; + var relativeDir = Path.Combine("uploads", scope).Replace('\\', '/'); + var root = GetWebRootPath(); + var fullDir = Path.Combine(root, "uploads", scope.Replace('/', Path.DirectorySeparatorChar)); + Directory.CreateDirectory(fullDir); + + var storedName = $"{Guid.NewGuid():N}{extension}"; + var relativePath = Path.Combine(relativeDir, storedName).Replace('\\', '/'); + var fullPath = Path.Combine(root, relativePath.Replace('/', Path.DirectorySeparatorChar)); + + await using var normalized = kind == ImageAssetKind.Avatar + ? await TelegramImageProcessor.PrepareAvatarJpegAsync(fileStream, cancellationToken) + : await TelegramImageProcessor.PrepareStoredImageJpegAsync(fileStream, cancellationToken: cancellationToken); + + await using var output = File.Create(fullPath); + normalized.Position = 0; + await normalized.CopyToAsync(output, cancellationToken); + + return new StoredImageAssetInfo(relativePath, fileName); + } + + public async Task SaveBrowserFileAsync( + IBrowserFile file, + string scope, + ImageAssetKind kind, + long maxBytes = 20 * 1024 * 1024, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(file); + await using var stream = file.OpenReadStream(maxBytes, cancellationToken); + return await SaveAsync(stream, file.Name, scope, kind, cancellationToken); + } + + public Task OpenReadAsync(string assetPath, CancellationToken cancellationToken = default) + { + assetPath = NormalizeAssetPath(assetPath); + var fullPath = Path.Combine(GetWebRootPath(), assetPath.Replace('/', Path.DirectorySeparatorChar)); + if (!File.Exists(fullPath)) + throw new FileNotFoundException("图片资产不存在", fullPath); + + Stream stream = File.OpenRead(fullPath); + return Task.FromResult(stream); + } + + public Task DeleteAssetAsync(string? assetPath, CancellationToken cancellationToken = default) + { + assetPath = NormalizeAssetPath(assetPath); + if (assetPath.Length == 0) + return Task.CompletedTask; + + var fullPath = Path.Combine(GetWebRootPath(), assetPath.Replace('/', Path.DirectorySeparatorChar)); + try + { + if (File.Exists(fullPath)) + File.Delete(fullPath); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "删除图片资产失败:{AssetPath}", assetPath); + } + + return Task.CompletedTask; + } + + public Task DeleteScopeAsync(string? scope, CancellationToken cancellationToken = default) + { + scope = NormalizeScope(scope); + if (scope.Length == 0) + return Task.CompletedTask; + + var fullDir = Path.Combine(GetWebRootPath(), "uploads", scope.Replace('/', Path.DirectorySeparatorChar)); + try + { + if (Directory.Exists(fullDir)) + Directory.Delete(fullDir, recursive: true); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "删除图片作用域失败:{Scope}", scope); + } + + return Task.CompletedTask; + } + + private string GetWebRootPath() + { + var path = _environment.WebRootPath; + if (string.IsNullOrWhiteSpace(path)) + path = Path.Combine(AppContext.BaseDirectory, "wwwroot"); + + Directory.CreateDirectory(path); + return path; + } + + private static string NormalizeScope(string? scope) + { + var raw = (scope ?? string.Empty).Trim().Replace('\\', '/'); + if (raw.Length == 0) + return string.Empty; + + var parts = raw.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(SanitizeSegment) + .Where(x => x.Length > 0) + .ToArray(); + + return string.Join('/', parts); + } + + private static string NormalizeAssetPath(string? assetPath) + { + var raw = (assetPath ?? string.Empty).Trim().Replace('\\', '/').TrimStart('/'); + if (raw.Contains("..", StringComparison.Ordinal)) + throw new InvalidOperationException("非法图片路径"); + + return raw; + } + + private static string SanitizeSegment(string value) + { + var chars = value + .Where(ch => char.IsLetterOrDigit(ch) || ch is '-' or '_' or '.') + .ToArray(); + return new string(chars); + } +} diff --git a/src/TelegramPanel.Web/Services/ScheduledTaskBackgroundService.cs b/src/TelegramPanel.Web/Services/ScheduledTaskBackgroundService.cs new file mode 100644 index 0000000..6975ea9 --- /dev/null +++ b/src/TelegramPanel.Web/Services/ScheduledTaskBackgroundService.cs @@ -0,0 +1,95 @@ +using TelegramPanel.Core.Services; +using TelegramPanel.Data.Entities; + +namespace TelegramPanel.Web.Services; + +/// +/// Cron 计划任务后台调度器。 +/// +public sealed class ScheduledTaskBackgroundService : BackgroundService +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + + public ScheduledTaskBackgroundService( + IServiceScopeFactory scopeFactory, + ILogger logger) + { + _scopeFactory = scopeFactory; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + await Task.Delay(TimeSpan.FromSeconds(3), stoppingToken); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + await ProcessOnceAsync(stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "计划任务调度循环执行失败"); + } + + await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken); + } + } + + private async Task ProcessOnceAsync(CancellationToken cancellationToken) + { + using var scope = _scopeFactory.CreateScope(); + var scheduledTaskService = scope.ServiceProvider.GetRequiredService(); + var batchTaskManagement = scope.ServiceProvider.GetRequiredService(); + + var enabledTasks = await scheduledTaskService.GetEnabledAsync(cancellationToken); + if (enabledTasks.Count == 0) + return; + + var nowUtc = DateTime.UtcNow; + foreach (var scheduledTask in enabledTasks) + { + cancellationToken.ThrowIfCancellationRequested(); + + var nextRunAtUtc = scheduledTask.NextRunAtUtc; + if (!nextRunAtUtc.HasValue) + { + await scheduledTaskService.AdvanceNextRunAsync(scheduledTask.Id, nowUtc, cancellationToken); + continue; + } + + if (nextRunAtUtc.Value > nowUtc) + continue; + + if (scheduledTask.LastBatchTaskId.HasValue) + { + var lastBatchTask = await batchTaskManagement.GetTaskAsync(scheduledTask.LastBatchTaskId.Value); + if (lastBatchTask != null && lastBatchTask.Status is "pending" or "running" or "paused") + { + _logger.LogInformation("计划任务 {ScheduledTaskId} 上次执行尚未结束,本轮跳过", scheduledTask.Id); + await scheduledTaskService.AdvanceNextRunAsync(scheduledTask.Id, nowUtc, cancellationToken); + continue; + } + } + + var task = new BatchTask + { + TaskType = scheduledTask.TaskType, + Total = Math.Max(0, scheduledTask.Total), + Completed = 0, + Failed = 0, + Config = TaskAssetScopeHelper.RemoveAssetScopeId(scheduledTask.ConfigJson) + }; + + var created = await batchTaskManagement.CreateTaskAsync(task); + _logger.LogInformation("计划任务触发:schedule={ScheduledTaskId}, batch={BatchTaskId}, type={TaskType}", scheduledTask.Id, created.Id, created.TaskType); + await scheduledTaskService.MarkTriggeredAsync(scheduledTask.Id, nowUtc, created.Id, cancellationToken); + } + } +} diff --git a/src/TelegramPanel.Web/Services/ScheduledTaskService.cs b/src/TelegramPanel.Web/Services/ScheduledTaskService.cs new file mode 100644 index 0000000..4d480f2 --- /dev/null +++ b/src/TelegramPanel.Web/Services/ScheduledTaskService.cs @@ -0,0 +1,143 @@ +using TelegramPanel.Core.Services; +using TelegramPanel.Data.Entities; +using TelegramPanel.Data.Repositories; + +namespace TelegramPanel.Web.Services; + +/// +/// 计划任务业务服务。 +/// +public sealed class ScheduledTaskService +{ + private readonly IScheduledTaskRepository _scheduledTaskRepository; + private readonly CronExpressionService _cronExpressionService; + private readonly ImageAssetStorageService _assetStorage; + + public ScheduledTaskService( + IScheduledTaskRepository scheduledTaskRepository, + CronExpressionService cronExpressionService, + ImageAssetStorageService assetStorage) + { + _scheduledTaskRepository = scheduledTaskRepository; + _cronExpressionService = cronExpressionService; + _assetStorage = assetStorage; + } + + public async Task> GetAllAsync(CancellationToken cancellationToken = default) + { + return await _scheduledTaskRepository.GetAllOrderedAsync(cancellationToken); + } + + public async Task GetAsync(int id, CancellationToken cancellationToken = default) + { + return await _scheduledTaskRepository.GetByIdAsync(id); + } + + public async Task CreateAsync(ScheduledTask task, CancellationToken cancellationToken = default) + { + Normalize(task); + task.CreatedAt = DateTime.UtcNow; + task.UpdatedAt = DateTime.UtcNow; + task.NextRunAtUtc = _cronExpressionService.GetNextOccurrenceUtc(task.CronExpression, DateTime.UtcNow); + return await _scheduledTaskRepository.AddAsync(task); + } + + public async Task UpdateAsync(ScheduledTask task, CancellationToken cancellationToken = default) + { + Normalize(task); + task.UpdatedAt = DateTime.UtcNow; + task.NextRunAtUtc = _cronExpressionService.GetNextOccurrenceUtc(task.CronExpression, DateTime.UtcNow); + await _scheduledTaskRepository.UpdateAsync(task); + return task; + } + + public async Task PauseAsync(int id, CancellationToken cancellationToken = default) + { + var task = await _scheduledTaskRepository.GetByIdAsync(id); + if (task == null) + return; + + task.Status = ScheduledTaskStatuses.Paused; + task.UpdatedAt = DateTime.UtcNow; + await _scheduledTaskRepository.UpdateAsync(task); + } + + public async Task ResumeAsync(int id, CancellationToken cancellationToken = default) + { + var task = await _scheduledTaskRepository.GetByIdAsync(id); + if (task == null) + return; + + task.Status = ScheduledTaskStatuses.Enabled; + task.UpdatedAt = DateTime.UtcNow; + task.NextRunAtUtc = _cronExpressionService.GetNextOccurrenceUtc(task.CronExpression, DateTime.UtcNow); + await _scheduledTaskRepository.UpdateAsync(task); + } + + public async Task DeleteAsync(int id, CancellationToken cancellationToken = default) + { + var task = await _scheduledTaskRepository.GetByIdAsync(id); + if (task == null) + return; + + if (!string.IsNullOrWhiteSpace(task.OwnedAssetScopeId)) + await _assetStorage.DeleteScopeAsync(task.OwnedAssetScopeId, cancellationToken); + + await _scheduledTaskRepository.DeleteAsync(task); + } + + public async Task> GetEnabledAsync(CancellationToken cancellationToken = default) + { + return await _scheduledTaskRepository.GetEnabledAsync(cancellationToken); + } + + public async Task MarkTriggeredAsync(int id, DateTime triggeredAtUtc, int batchTaskId, CancellationToken cancellationToken = default) + { + var task = await _scheduledTaskRepository.GetByIdAsync(id); + if (task == null) + return; + + task.LastRunAtUtc = triggeredAtUtc; + task.LastBatchTaskId = batchTaskId; + task.UpdatedAt = DateTime.UtcNow; + task.NextRunAtUtc = _cronExpressionService.GetNextOccurrenceUtc(task.CronExpression, triggeredAtUtc); + await _scheduledTaskRepository.UpdateAsync(task); + } + + public async Task AdvanceNextRunAsync(int id, DateTime fromUtc, CancellationToken cancellationToken = default) + { + var task = await _scheduledTaskRepository.GetByIdAsync(id); + if (task == null) + return; + + task.UpdatedAt = DateTime.UtcNow; + task.NextRunAtUtc = _cronExpressionService.GetNextOccurrenceUtc(task.CronExpression, fromUtc); + await _scheduledTaskRepository.UpdateAsync(task); + } + + public string ValidateCronOrThrow(string expression) + { + if (!_cronExpressionService.TryValidate(expression, out var error)) + throw new InvalidOperationException(error ?? "Cron 表达式无效"); + return expression.Trim(); + } + + private void Normalize(ScheduledTask task) + { + task.TaskType = (task.TaskType ?? string.Empty).Trim(); + task.Status = string.Equals((task.Status ?? string.Empty).Trim(), ScheduledTaskStatuses.Paused, StringComparison.OrdinalIgnoreCase) + ? ScheduledTaskStatuses.Paused + : ScheduledTaskStatuses.Enabled; + task.CronExpression = ValidateCronOrThrow(task.CronExpression); + task.ConfigJson = string.IsNullOrWhiteSpace(task.ConfigJson) ? null : task.ConfigJson.Trim(); + task.OwnedAssetScopeId = NormalizeNullable(task.OwnedAssetScopeId); + if (task.Total < 0) + task.Total = 0; + } + + private static string? NormalizeNullable(string? value) + { + var text = (value ?? string.Empty).Trim(); + return text.Length == 0 ? null : text; + } +} diff --git a/src/TelegramPanel.Web/Services/TaskAssetScopeHelper.cs b/src/TelegramPanel.Web/Services/TaskAssetScopeHelper.cs new file mode 100644 index 0000000..f9f3960 --- /dev/null +++ b/src/TelegramPanel.Web/Services/TaskAssetScopeHelper.cs @@ -0,0 +1,79 @@ +using System.Text.Json.Nodes; + +namespace TelegramPanel.Web.Services; + +/// +/// 任务配置中的图片资产作用域辅助工具。 +/// +public static class TaskAssetScopeHelper +{ + private const string AssetScopeIdProperty = "asset_scope_id"; + + public static string? GetAssetScopeId(string? configJson) + { + if (string.IsNullOrWhiteSpace(configJson)) + return null; + + try + { + var node = JsonNode.Parse(configJson) as JsonObject; + var scopeId = node?[AssetScopeIdProperty]?.GetValue(); + return Normalize(scopeId); + } + catch + { + return null; + } + } + + public static string? RemoveAssetScopeId(string? configJson) + { + if (string.IsNullOrWhiteSpace(configJson)) + return configJson; + + try + { + var node = JsonNode.Parse(configJson) as JsonObject; + if (node == null) + return configJson; + + node.Remove(AssetScopeIdProperty); + return node.ToJsonString(new System.Text.Json.JsonSerializerOptions { WriteIndented = true }); + } + catch + { + return configJson; + } + } + + public static string? SetAssetScopeId(string? configJson, string? scopeId) + { + if (string.IsNullOrWhiteSpace(configJson)) + return configJson; + + try + { + var node = JsonNode.Parse(configJson) as JsonObject; + if (node == null) + return configJson; + + var normalized = Normalize(scopeId); + if (normalized == null) + node.Remove(AssetScopeIdProperty); + else + node[AssetScopeIdProperty] = normalized; + + return node.ToJsonString(new System.Text.Json.JsonSerializerOptions { WriteIndented = true }); + } + catch + { + return configJson; + } + } + + private static string? Normalize(string? value) + { + var text = (value ?? string.Empty).Trim(); + return text.Length == 0 ? null : text; + } +} diff --git a/src/TelegramPanel.Web/Services/TemplateRenderingService.cs b/src/TelegramPanel.Web/Services/TemplateRenderingService.cs new file mode 100644 index 0000000..26d9bab --- /dev/null +++ b/src/TelegramPanel.Web/Services/TemplateRenderingService.cs @@ -0,0 +1,78 @@ +using System.Text; +using System.Text.RegularExpressions; + +namespace TelegramPanel.Web.Services; + +/// +/// 模板变量解析服务。 +/// +public sealed class TemplateRenderingService +{ + private static readonly Regex TokenRegex = new("\\{(?[a-zA-Z0-9_]+)\\}", RegexOptions.Compiled); + private readonly DataDictionaryService _dataDictionaryService; + + public TemplateRenderingService(DataDictionaryService dataDictionaryService) + { + _dataDictionaryService = dataDictionaryService; + } + + public async Task RenderTextTemplateAsync(string template, CancellationToken cancellationToken = default) + { + template = template ?? string.Empty; + var matches = TokenRegex.Matches(template); + if (matches.Count == 0) + return template; + + var cache = new Dictionary(StringComparer.OrdinalIgnoreCase); + var builder = new StringBuilder(); + var lastIndex = 0; + + foreach (Match match in matches) + { + if (!match.Success) + continue; + + builder.Append(template, lastIndex, match.Index - lastIndex); + var tokenName = match.Groups["name"].Value; + if (!cache.TryGetValue(tokenName, out var resolved)) + { + resolved = await ResolveTextTokenAsync(tokenName, cancellationToken); + cache[tokenName] = resolved; + } + + builder.Append(resolved); + lastIndex = match.Index + match.Length; + } + + builder.Append(template, lastIndex, template.Length - lastIndex); + return builder.ToString(); + } + + public async Task ResolveImageTemplateAsync(string tokenExpression, CancellationToken cancellationToken = default) + { + var tokenName = ExtractSingleTokenName(tokenExpression) + ?? throw new InvalidOperationException("图片变量必须是单个字典变量,例如 {avatar}"); + return await _dataDictionaryService.ResolveImageValueAsync(tokenName, cancellationToken); + } + + public string? ExtractSingleTokenName(string? tokenExpression) + { + var text = (tokenExpression ?? string.Empty).Trim(); + if (text.Length == 0) + return null; + + var match = TokenRegex.Match(text); + if (!match.Success || match.Index != 0 || match.Length != text.Length) + return null; + + return match.Groups["name"].Value; + } + + private async Task ResolveTextTokenAsync(string tokenName, CancellationToken cancellationToken) + { + if (string.Equals(tokenName, "time", StringComparison.OrdinalIgnoreCase)) + return DateTime.Now.ToString("yyyyMMddHHmmss"); + + return await _dataDictionaryService.ResolveTextValueAsync(tokenName, cancellationToken); + } +} From b134c335b248d09fc18e939d9040d960fc61951c Mon Sep 17 00:00:00 2001 From: meoacgx Date: Tue, 10 Mar 2026 00:25:48 +0800 Subject: [PATCH 02/13] fix: stabilize task pause and auto sync scheduling --- .../AccountDataAutoSyncBackgroundService.cs | 61 +++++++++++++++++-- .../Services/UserChatActiveTaskHandler.cs | 38 +++++++++++- 2 files changed, 91 insertions(+), 8 deletions(-) diff --git a/src/TelegramPanel.Web/Services/AccountDataAutoSyncBackgroundService.cs b/src/TelegramPanel.Web/Services/AccountDataAutoSyncBackgroundService.cs index 0785b60..2d37694 100644 --- a/src/TelegramPanel.Web/Services/AccountDataAutoSyncBackgroundService.cs +++ b/src/TelegramPanel.Web/Services/AccountDataAutoSyncBackgroundService.cs @@ -16,6 +16,7 @@ public sealed class AccountDataAutoSyncBackgroundService : BackgroundService private readonly IConfiguration _configuration; private readonly IWebHostEnvironment _environment; private readonly ILogger _logger; + private DateTime? _lastAutoSyncAtUtc; public AccountDataAutoSyncBackgroundService( IServiceScopeFactory scopeFactory, @@ -48,7 +49,7 @@ public sealed class AccountDataAutoSyncBackgroundService : BackgroundService } var nowUtc = DateTime.UtcNow; - var lastUtc = ReadLastAutoSyncAtUtcFromConfig(); + var lastUtc = GetLastAutoSyncAtUtc(); if (lastUtc.HasValue) { var nextUtc = lastUtc.Value.AddHours(hours); @@ -86,7 +87,9 @@ public sealed class AccountDataAutoSyncBackgroundService : BackgroundService summary.AccountFailures.Count); // 记录“上次自动同步时间”,用于容器重启后继续按间隔调度,避免“重启即跑一轮”导致限流。 - await PersistLastAutoSyncAtUtcAsync(DateTime.UtcNow, stoppingToken); + var completedAtUtc = DateTime.UtcNow; + _lastAutoSyncAtUtc = completedAtUtc; + await PersistLastAutoSyncAtUtcAsync(completedAtUtc, stoppingToken); } catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { @@ -107,13 +110,43 @@ public sealed class AccountDataAutoSyncBackgroundService : BackgroundService } } - private DateTime? ReadLastAutoSyncAtUtcFromConfig() + private DateTime? GetLastAutoSyncAtUtc() { - var raw = (_configuration["Sync:LastAutoSyncAtUtc"] ?? "").Trim(); - if (string.IsNullOrWhiteSpace(raw)) + var configValue = ReadUtcValue(_configuration["Sync:LastAutoSyncAtUtc"]); + var fileValue = ReadLastAutoSyncAtUtcFromLocalConfig(); + return MaxUtc(_lastAutoSyncAtUtc, fileValue, configValue); + } + + private DateTime? ReadLastAutoSyncAtUtcFromLocalConfig() + { + try + { + var path = LocalConfigFile.ResolvePath(_configuration, _environment); + if (!File.Exists(path)) + return null; + + var json = File.ReadAllText(path); + if (string.IsNullOrWhiteSpace(json)) + return null; + + var root = JsonNode.Parse(json)?.AsObject(); + var raw = root?["Sync"]?["LastAutoSyncAtUtc"]?.GetValue(); + return ReadUtcValue(raw); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to read Sync:LastAutoSyncAtUtc from local config (ignored)"); + return null; + } + } + + private static DateTime? ReadUtcValue(string? raw) + { + var value = (raw ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(value)) return null; - if (DateTime.TryParse(raw, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var parsed)) + if (DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var parsed)) { if (parsed.Kind == DateTimeKind.Unspecified) parsed = DateTime.SpecifyKind(parsed, DateTimeKind.Utc); @@ -123,6 +156,22 @@ public sealed class AccountDataAutoSyncBackgroundService : BackgroundService return null; } + private static DateTime? MaxUtc(params DateTime?[] values) + { + DateTime? max = null; + foreach (var value in values) + { + if (!value.HasValue) + continue; + + var utc = value.Value.Kind == DateTimeKind.Utc ? value.Value : value.Value.ToUniversalTime(); + if (!max.HasValue || utc > max.Value) + max = utc; + } + + return max; + } + private async Task PersistLastAutoSyncAtUtcAsync(DateTime utcNow, CancellationToken cancellationToken) { try diff --git a/src/TelegramPanel.Web/Services/UserChatActiveTaskHandler.cs b/src/TelegramPanel.Web/Services/UserChatActiveTaskHandler.cs index 53ee9fd..12532fe 100644 --- a/src/TelegramPanel.Web/Services/UserChatActiveTaskHandler.cs +++ b/src/TelegramPanel.Web/Services/UserChatActiveTaskHandler.cs @@ -24,6 +24,8 @@ public sealed class UserChatActiveTaskHandler : IModuleTaskHandler var config = DeserializeConfig(host.Config); ValidateAndNormalizeConfig(config); + config.Canceled = false; + config.Error = null; var selectedCategoryIds = NormalizeSelectedCategoryIds(config).ToHashSet(); var allAccounts = (await accountManagement.GetAllAccountsAsync()) @@ -116,6 +118,12 @@ public sealed class UserChatActiveTaskHandler : IModuleTaskHandler var messageIdx = SelectIndex(config.MessageMode, config.Dictionary.Count, ref messageQueueIndex); var text = config.Dictionary[messageIdx]; + if (!await host.IsStillRunningAsync(cancellationToken)) + { + config.Canceled = true; + break; + } + var send = await accountTools.SendMessageToResolvedChatAsync( accountSlot.Account.Id, targetSlot.Resolved, @@ -155,8 +163,11 @@ public sealed class UserChatActiveTaskHandler : IModuleTaskHandler break; var delayMs = NextDelayMilliseconds(config.DelayMinMs, config.DelayMaxMs); - if (delayMs > 0) - await Task.Delay(delayMs, cancellationToken); + if (delayMs > 0 && !await DelayWithPauseCheckAsync(host, delayMs, cancellationToken)) + { + config.Canceled = true; + break; + } } } catch (Exception ex) @@ -298,6 +309,29 @@ public sealed class UserChatActiveTaskHandler : IModuleTaskHandler return Random.Shared.Next(minMs, maxMs + 1); } + private static async Task DelayWithPauseCheckAsync( + 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 bool ShouldPersistProgress(int completed, bool hadFailureThisRound, DateTime lastPersistAt) { if (completed <= 1) From c2a680d5c4f2505f2ff051f527257c7a6c9715f1 Mon Sep 17 00:00:00 2001 From: meoacgx Date: Tue, 10 Mar 2026 00:42:33 +0800 Subject: [PATCH 03/13] fix: correct data dictionary variable placeholder --- src/TelegramPanel.Web/Components/Pages/DataDictionaries.razor | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/TelegramPanel.Web/Components/Pages/DataDictionaries.razor b/src/TelegramPanel.Web/Components/Pages/DataDictionaries.razor index fd244b8..6ffa13b 100644 --- a/src/TelegramPanel.Web/Components/Pages/DataDictionaries.razor +++ b/src/TelegramPanel.Web/Components/Pages/DataDictionaries.razor @@ -46,7 +46,7 @@ @context.DisplayName - @($"{" + context.Name + "}") + @($"{{{context.Name}}}") @(IsImageDictionary(context) ? "图片" : "文本") @@ -206,3 +206,4 @@ private static bool IsQueueMode(DataDictionary dictionary) => string.Equals(dictionary.ReadMode, DataDictionaryReadModes.Queue, StringComparison.OrdinalIgnoreCase); } + From 7f821fb96d2bfa01a6cca80ddaaa1c0db45806b2 Mon Sep 17 00:00:00 2001 From: meoacgx Date: Tue, 10 Mar 2026 01:19:38 +0800 Subject: [PATCH 04/13] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E5=AD=97=E5=85=B8=E4=B8=8E=E8=B4=A6=E5=8F=B7=E6=89=B9?= =?UTF-8?q?=E9=87=8F=E8=B5=84=E6=96=99=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TelegramPanel.sln | 20 +- docs/developer/modules.md | 53 +- .../MessageReportTaskEditor.razor | 312 +++++++++++ .../MessageReportTaskHandler.cs | 312 +++++++++++ .../MessageReportTaskModels.cs | 266 ++++++++++ .../MessageReportTaskModule.cs | 56 ++ .../MessageReportTaskRerunBuilder.cs | 75 +++ .../MessageReportTaskTelegramService.cs | 419 +++++++++++++++ ...TelegramPanel.Modules.MessageReport.csproj | 23 + .../_Imports.razor | 4 + .../manifest.json | 11 + .../Dialogs/MessageReportTaskEditor.razor | 315 ++++++++++++ .../MessageReportTaskModule.cs | 58 +++ .../Services/MessageReportTaskHandler.cs | 429 ++++++++++++++++ .../Services/MessageReportTaskModels.cs | 86 ++++ .../Services/MessageReportTaskRerunBuilder.cs | 132 +++++ .../Services/TelegramMessageReportService.cs | 483 ++++++++++++++++++ ...gramPanel.Modules.MessageReportTask.csproj | 33 ++ .../_Imports.razor | 5 + .../manifest.json | 13 + .../Dialogs/BatchChangeAvatarDialog.razor | 316 ++++++++++++ .../Dialogs/BatchChangeNicknameDialog.razor | 113 +++- .../Dialogs/BatchChangeUsernameDialog.razor | 159 +++--- .../Components/Pages/AccountImport.razor | 24 + .../Components/Pages/Accounts.razor | 24 + .../Components/Pages/DataDictionaries.razor | 6 + .../Services/TemplateRenderingService.cs | 71 +++ 27 files changed, 3707 insertions(+), 111 deletions(-) create mode 100644 src/TelegramPanel.Modules.MessageReport/MessageReportTaskEditor.razor create mode 100644 src/TelegramPanel.Modules.MessageReport/MessageReportTaskHandler.cs create mode 100644 src/TelegramPanel.Modules.MessageReport/MessageReportTaskModels.cs create mode 100644 src/TelegramPanel.Modules.MessageReport/MessageReportTaskModule.cs create mode 100644 src/TelegramPanel.Modules.MessageReport/MessageReportTaskRerunBuilder.cs create mode 100644 src/TelegramPanel.Modules.MessageReport/MessageReportTaskTelegramService.cs create mode 100644 src/TelegramPanel.Modules.MessageReport/TelegramPanel.Modules.MessageReport.csproj create mode 100644 src/TelegramPanel.Modules.MessageReport/_Imports.razor create mode 100644 src/TelegramPanel.Modules.MessageReport/manifest.json create mode 100644 src/TelegramPanel.Modules.MessageReportTask/Components/Dialogs/MessageReportTaskEditor.razor create mode 100644 src/TelegramPanel.Modules.MessageReportTask/MessageReportTaskModule.cs create mode 100644 src/TelegramPanel.Modules.MessageReportTask/Services/MessageReportTaskHandler.cs create mode 100644 src/TelegramPanel.Modules.MessageReportTask/Services/MessageReportTaskModels.cs create mode 100644 src/TelegramPanel.Modules.MessageReportTask/Services/MessageReportTaskRerunBuilder.cs create mode 100644 src/TelegramPanel.Modules.MessageReportTask/Services/TelegramMessageReportService.cs create mode 100644 src/TelegramPanel.Modules.MessageReportTask/TelegramPanel.Modules.MessageReportTask.csproj create mode 100644 src/TelegramPanel.Modules.MessageReportTask/_Imports.razor create mode 100644 src/TelegramPanel.Modules.MessageReportTask/manifest.json create mode 100644 src/TelegramPanel.Web/Components/Dialogs/BatchChangeAvatarDialog.razor diff --git a/TelegramPanel.sln b/TelegramPanel.sln index 301f599..a899d77 100644 --- a/TelegramPanel.sln +++ b/TelegramPanel.sln @@ -1,4 +1,4 @@ -Microsoft Visual Studio Solution File, Format Version 12.00 +Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.0.31903.59 MinimumVisualStudioVersion = 10.0.40219.1 @@ -10,6 +10,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TelegramPanel.Data", "src\T EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TelegramPanel.Modules.Abstractions", "src\TelegramPanel.Modules.Abstractions\TelegramPanel.Modules.Abstractions.csproj", "{D47E13D1-8E3C-4F40-9B6D-4B8E17A8C5F1}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{FDD46A28-5D10-4E16-A8B7-9C8612500D02}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TelegramPanel.Modules.MessageReport", "src\TelegramPanel.Modules.MessageReport\TelegramPanel.Modules.MessageReport.csproj", "{21B1CE10-B6E1-46FB-B580-16065D18EDD3}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TelegramPanel.Modules.MessageReportTask", "src\TelegramPanel.Modules.MessageReportTask\TelegramPanel.Modules.MessageReportTask.csproj", "{E48333AF-2411-4219-AD2F-6ABF45960100}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -32,5 +38,17 @@ Global {D47E13D1-8E3C-4F40-9B6D-4B8E17A8C5F1}.Debug|Any CPU.Build.0 = Debug|Any CPU {D47E13D1-8E3C-4F40-9B6D-4B8E17A8C5F1}.Release|Any CPU.ActiveCfg = Release|Any CPU {D47E13D1-8E3C-4F40-9B6D-4B8E17A8C5F1}.Release|Any CPU.Build.0 = Release|Any CPU + {21B1CE10-B6E1-46FB-B580-16065D18EDD3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {21B1CE10-B6E1-46FB-B580-16065D18EDD3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {21B1CE10-B6E1-46FB-B580-16065D18EDD3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {21B1CE10-B6E1-46FB-B580-16065D18EDD3}.Release|Any CPU.Build.0 = Release|Any CPU + {E48333AF-2411-4219-AD2F-6ABF45960100}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E48333AF-2411-4219-AD2F-6ABF45960100}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E48333AF-2411-4219-AD2F-6ABF45960100}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E48333AF-2411-4219-AD2F-6ABF45960100}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {21B1CE10-B6E1-46FB-B580-16065D18EDD3} = {FDD46A28-5D10-4E16-A8B7-9C8612500D02} + {E48333AF-2411-4219-AD2F-6ABF45960100} = {FDD46A28-5D10-4E16-A8B7-9C8612500D02} EndGlobalSection EndGlobal diff --git a/docs/developer/modules.md b/docs/developer/modules.md index 345be30..be65553 100644 --- a/docs/developer/modules.md +++ b/docs/developer/modules.md @@ -534,7 +534,56 @@ yield return new ModuleTaskDefinition > 注意:这组字段已经进入抽象层,并且内置持续任务已按此方式声明;外部模块也建议遵循相同结构,便于后续宿主统一扩展任务中心行为。 -### 6) 为“重新运行”提供专用构建器(适合复杂任务) +### 6) 宿主内置数据字典与模板变量(推荐优先复用) + +如果你的模块任务需要“随机文案 / 队列文案 / 图片变量 / 标题模板 / 用户名模板”等能力,建议优先复用宿主已经内置的数据字典体系,而不是在模块里重复造一套词库配置。 + +当前宿主已经提供: + +- 数据字典管理页面:`/data-dictionaries` +- 文本字典:返回 `string` +- 图片字典:返回图片资产引用(适合头像、图片消息等) +- 读取模式:`random` / `queue` +- 队列游标持久化:`queue` 模式的 `NextIndex` 会写入数据库,重启后继续 +- 模板变量语法:固定为 `{name}` +- 内置变量:`{time}`(格式 `yyyyMMddHHmmss`) + +相关宿主服务: + +- `TelegramPanel.Web.Services.DataDictionaryService` +- `TelegramPanel.Web.Services.TemplateRenderingService` +- `TelegramPanel.Web.Services.ImageAssetStorageService` + +推荐用法: + +```csharp +var templateRendering = host.Services.GetRequiredService(); + +var title = await templateRendering.RenderTextTemplateAsync("临时频道{time}_{city}", cancellationToken); +var avatar = await templateRendering.ResolveImageTemplateAsync("{avatar_dict}", cancellationToken); +``` + +约束说明: + +- 标题、描述、公开用户名这类文本字段,只能解析到**文本值** +- 头像、图片消息这类图片字段,只能使用**固定图片**或**图片字典变量** +- 文本字典和图片字典**严格分型**,不要混用 +- 未知变量、空字典、已停用字典、类型不匹配,宿主会直接抛出校验失败 +- 图片变量必须是**单个 token**,例如 `{avatar}`,不能写成 `头像_{avatar}` + +如果你的模块也提供任务编辑器,建议: + +- 在 UI 中直接提示“支持 `{time}` 与 `{字典名}`” +- 文本输入框只展示文本字典变量 +- 图片输入框只展示图片字典变量 +- 让最终配置 JSON 只保存模板字符串 / 字典 token,不要把解析后的随机结果提前固化进配置 + +这样做的好处是: + +- 宿主统一管理字典内容,模块间可以复用同一份变量源 +- 后续扩展新变量 provider 时,模块通常不需要改协议 +- 计划任务、一次性任务、模块页面都能复用同一套解析规则 +### 7) 为“重新运行”提供专用构建器(适合复杂任务) 如果你的任务配置在运行过程中会写回运行态字段,或者重跑前需要清洗旧配置,建议额外实现 `IModuleTaskRerunBuilder`: @@ -666,3 +715,5 @@ public IEnumerable GetApis(ModuleHostContext context) + + diff --git a/src/TelegramPanel.Modules.MessageReport/MessageReportTaskEditor.razor b/src/TelegramPanel.Modules.MessageReport/MessageReportTaskEditor.razor new file mode 100644 index 0000000..52bcda7 --- /dev/null +++ b/src/TelegramPanel.Modules.MessageReport/MessageReportTaskEditor.razor @@ -0,0 +1,312 @@ +@using System.Text.Json +@using TelegramPanel.Core.Services +@using TelegramPanel.Data.Entities +@inject AccountCategoryManagementService CategoryManagement +@inject AccountManagementService AccountManagement + + + + 该任务会按账号分类轮流举报指定消息。MaxReports=0 表示持续运行直到你在任务中心手动暂停/停止;如果在外层选择 Cron 计划,建议配合有限次数使用。 + + + @if (loading) + { + + 正在加载账号分类... + } + else + { + + @foreach (var category in categories) + { + @category.Name + } + + + + 当前选中分类下可用账号数:@selectedAccountCount + + + + + + + + + + + + + + + + + + 垃圾/广告 + 暴力 + 色情/成人 + 儿童/未成年相关 + 版权/侵权 + 隐私/个人信息 + 毒品/违禁药 + 诈骗/冒充/虚假 + 其他 + 自动(取 Telegram 返回的首项) + + + + + + + + 队列循环 + 随机 + + } + + +@code { + [Parameter] public ModuleTaskDraft Draft { get; set; } + [Parameter] public EventCallback DraftChanged { get; set; } + [Parameter] public string? InitialConfigJson { get; set; } + + private List categories = new(); + private List activeAccounts = new(); + private bool loading = true; + private bool initializedFromConfig; + + private IEnumerable selectedCategoryIds = Array.Empty(); + private string messageReferencesText = string.Empty; + private int delayMinSeconds = 15; + private int delayMaxSeconds = 45; + private int maxReports; + private string reportType = MessageReportTaskReportTypes.Spam; + private string optionKeywordsText = string.Empty; + private string commentsText = string.Empty; + private string commentMode = MessageReportTaskModes.Queue; + + private int runtimeCompletedAttempts; + private int runtimeFailedAttempts; + private int runtimeAccountCursor; + private int runtimeMessageCursor; + private int runtimeCommentCursor; + private List runtimeRecentFailures = new(); + + private int selectedAccountCount => activeAccounts.Count(x => + x.IsActive + && x.UserId > 0 + && x.CategoryId.HasValue + && selectedCategoryIds.Contains(x.CategoryId.Value) + && x.Category?.ExcludeFromOperations != true); + + protected override async Task OnInitializedAsync() + { + ApplyInitialConfigIfNeeded(); + + try + { + var categoriesTask = CategoryManagement.GetAllCategoriesAsync(); + var accountsTask = AccountManagement.GetAllAccountsAsync(); + await Task.WhenAll(categoriesTask, accountsTask); + + categories = categoriesTask.Result + .Where(x => !x.ExcludeFromOperations) + .OrderBy(x => x.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + + activeAccounts = accountsTask.Result.ToList(); + + if (!selectedCategoryIds.Any()) + { + var firstCategoryId = categories.FirstOrDefault()?.Id ?? 0; + selectedCategoryIds = firstCategoryId > 0 ? new[] { firstCategoryId } : Array.Empty(); + } + } + finally + { + loading = false; + await PushDraftAsync(); + } + } + + private void ApplyInitialConfigIfNeeded() + { + if (initializedFromConfig) + return; + + initializedFromConfig = true; + var raw = (InitialConfigJson ?? string.Empty).Trim(); + if (raw.Length == 0) + return; + + try + { + var cfg = JsonSerializer.Deserialize(raw); + if (cfg == null) + return; + + var categoryIds = MessageReportTaskInputHelper.NormalizeCategoryIds(cfg.CategoryIds, cfg.CategoryId); + if (categoryIds.Count > 0) + selectedCategoryIds = categoryIds; + + messageReferencesText = string.Join(Environment.NewLine, cfg.MessageReferences ?? new List()); + delayMinSeconds = ConvertMillisecondsToSeconds(cfg.DelayMinMs); + delayMaxSeconds = ConvertMillisecondsToSeconds(cfg.DelayMaxMs); + maxReports = Math.Max(0, cfg.MaxReports); + reportType = MessageReportTaskReportTypes.Normalize(cfg.ReportType); + optionKeywordsText = string.Join(Environment.NewLine, cfg.OptionKeywords ?? new List()); + commentsText = string.Join(Environment.NewLine, cfg.Comments ?? new List()); + commentMode = MessageReportTaskModes.Normalize(cfg.CommentMode); + + runtimeCompletedAttempts = Math.Max(0, cfg.CompletedAttempts); + runtimeFailedAttempts = Math.Max(0, cfg.FailedAttempts); + runtimeAccountCursor = Math.Max(0, cfg.AccountCursor); + runtimeMessageCursor = Math.Max(0, cfg.MessageCursor); + runtimeCommentCursor = Math.Max(0, cfg.CommentCursor); + runtimeRecentFailures = cfg.RecentFailures?.TakeLast(20).ToList() ?? new List(); + } + catch + { + } + } + + private async Task OnSelectedCategoriesChanged(IEnumerable values) + { + selectedCategoryIds = MessageReportTaskInputHelper.NormalizeCategoryIds(values, 0); + await OnInputsChanged(); + } + + private async Task OnInputsChanged() + { + await PushDraftAsync(); + } + + private async Task PushDraftAsync() + { + var selectedIds = MessageReportTaskInputHelper.NormalizeCategoryIds(selectedCategoryIds, 0); + var selectedCategories = categories.Where(x => selectedIds.Contains(x.Id)).ToList(); + var messageReferences = MessageReportTaskInputHelper.ParseMultilineText(messageReferencesText, distinct: true); + var optionKeywords = MessageReportTaskInputHelper.ParseMultilineText(optionKeywordsText, distinct: true); + var comments = MessageReportTaskInputHelper.ParseMultilineText(commentsText, distinct: false); + + if (loading) + { + await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "账号分类加载中")); + return; + } + + if (categories.Count == 0) + { + await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "没有可用账号分类(或都被标记为排除操作)")); + return; + } + + if (selectedCategories.Count == 0) + { + await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "请至少选择一个执行账号分类")); + return; + } + + if (selectedAccountCount <= 0) + { + await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "所选分类下没有可用执行账号")); + return; + } + + if (messageReferences.Count == 0) + { + await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "请至少填写一个消息引用")); + return; + } + + foreach (var reference in messageReferences) + { + if (!MessageReportTaskInputHelper.TryParseMessageReference(reference, out _, out var error)) + { + await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, $"消息引用无效:{reference}({error})")); + return; + } + } + + if (delayMinSeconds < 0 || delayMaxSeconds < 0) + { + await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "间隔不能为负数")); + return; + } + + if (delayMaxSeconds < delayMinSeconds) + { + await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "最大间隔不能小于最小间隔")); + return; + } + + if (maxReports < 0) + { + await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "最多举报次数不能小于 0")); + return; + } + + var cfg = new MessageReportTaskConfig + { + CategoryId = selectedCategories[0].Id, + CategoryName = selectedCategories[0].Name, + CategoryIds = selectedCategories.Select(x => x.Id).ToList(), + CategoryNames = selectedCategories.Select(x => x.Name).ToList(), + MessageReferences = messageReferences, + DelayMinMs = delayMinSeconds * 1000, + DelayMaxMs = delayMaxSeconds * 1000, + MaxReports = maxReports, + ReportType = MessageReportTaskReportTypes.Normalize(reportType), + OptionKeywords = optionKeywords, + Comments = comments, + CommentMode = MessageReportTaskModes.Normalize(commentMode), + CompletedAttempts = runtimeCompletedAttempts, + FailedAttempts = runtimeFailedAttempts, + AccountCursor = runtimeAccountCursor, + MessageCursor = runtimeMessageCursor, + CommentCursor = runtimeCommentCursor, + Canceled = false, + Error = null, + RecentFailures = runtimeRecentFailures.ToList() + }; + + var total = maxReports > 0 ? maxReports : 0; + var json = JsonSerializer.Serialize(cfg, new JsonSerializerOptions { WriteIndented = true }); + await DraftChanged.InvokeAsync(new ModuleTaskDraft(total, json, true, null)); + } + + private static int ConvertMillisecondsToSeconds(int milliseconds) + { + if (milliseconds <= 0) + return 0; + return (int)Math.Ceiling(milliseconds / 1000d); + } +} diff --git a/src/TelegramPanel.Modules.MessageReport/MessageReportTaskHandler.cs b/src/TelegramPanel.Modules.MessageReport/MessageReportTaskHandler.cs new file mode 100644 index 0000000..1263b7f --- /dev/null +++ b/src/TelegramPanel.Modules.MessageReport/MessageReportTaskHandler.cs @@ -0,0 +1,312 @@ +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using TelegramPanel.Core.Services; +using TelegramPanel.Core.Services.Telegram; +using TelegramPanel.Data.Entities; +using TelegramPanel.Modules; + +namespace TelegramPanel.Modules.MessageReport; + +public sealed class MessageReportTaskHandler : IModuleTaskHandler +{ + private const int MaxFailureLines = 20; + + public string TaskType => MessageReportTaskTypes.TaskType; + + public async Task ExecuteAsync(IModuleTaskExecutionHost host, CancellationToken cancellationToken) + { + var logger = host.Services.GetRequiredService>(); + var taskManagement = host.Services.GetRequiredService(); + var accountManagement = host.Services.GetRequiredService(); + var telegramService = host.Services.GetRequiredService(); + + var config = DeserializeConfig(host.Config); + ValidateAndNormalizeConfig(config); + + var selectedCategoryIds = MessageReportTaskInputHelper.NormalizeCategoryIds(config.CategoryIds, config.CategoryId).ToHashSet(); + var allAccounts = (await accountManagement.GetAllAccountsAsync()) + .Where(x => x.IsActive && x.UserId > 0 && x.Category?.ExcludeFromOperations != true) + .Where(x => x.CategoryId.HasValue && selectedCategoryIds.Contains(x.CategoryId.Value)) + .OrderBy(x => x.Id) + .ToList(); + + if (allAccounts.Count == 0) + { + config.Error = "所选分类下没有可用执行账号"; + await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(config)); + throw new InvalidOperationException(config.Error); + } + + var parsedMessageReferences = config.MessageReferences + .Select(raw => + { + if (!MessageReportTaskInputHelper.TryParseMessageReference(raw, out var parsed, out var error) || parsed == null) + throw new InvalidOperationException($"消息引用无效:{raw}({error})"); + return parsed; + }) + .ToList(); + + var completed = Math.Max(0, config.CompletedAttempts); + var failed = Math.Max(0, config.FailedAttempts); + var accountCursor = NormalizeCursor(config.AccountCursor, allAccounts.Count); + var messageCursor = NormalizeCursor(config.MessageCursor, parsedMessageReferences.Count); + var commentCursor = Math.Max(0, config.CommentCursor); + var resolvedCache = new Dictionary(StringComparer.OrdinalIgnoreCase); + + config.Canceled = false; + config.Error = null; + config.CompletedAttempts = completed; + config.FailedAttempts = failed; + config.AccountCursor = accountCursor; + config.MessageCursor = messageCursor; + config.CommentCursor = commentCursor; + await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(config)); + await host.UpdateProgressAsync(completed, failed, cancellationToken); + + while (!cancellationToken.IsCancellationRequested) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (!await host.IsStillRunningAsync(cancellationToken)) + { + config.Canceled = true; + config.Error = null; + config.CompletedAttempts = completed; + config.FailedAttempts = failed; + config.AccountCursor = accountCursor; + config.MessageCursor = messageCursor; + config.CommentCursor = commentCursor; + await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(config)); + return; + } + + if (config.MaxReports > 0 && completed >= config.MaxReports) + break; + + var account = allAccounts[SelectIndex(allAccounts.Count, ref accountCursor)]; + var messageReference = parsedMessageReferences[SelectIndex(parsedMessageReferences.Count, ref messageCursor)]; + var cacheKey = $"{account.Id}:{messageReference.CanonicalId}"; + + string? failureReason = null; + try + { + if (!resolvedCache.TryGetValue(cacheKey, out var resolvedTarget)) + { + var resolved = await telegramService.ResolveMessageReferenceAsync(account.Id, messageReference.Raw, cancellationToken); + if (!resolved.Success || resolved.Target == null) + { + failureReason = NormalizeReason(resolved.Error); + } + else + { + resolvedTarget = resolved.Target; + resolvedCache[cacheKey] = resolvedTarget; + } + } + + if (failureReason == null && resolvedCache.TryGetValue(cacheKey, out var cachedTarget)) + { + var report = await telegramService.ReportMessageAsync(account.Id, cachedTarget, config, commentCursor, cancellationToken); + commentCursor = Math.Max(0, report.NextCommentCursor); + if (!string.IsNullOrWhiteSpace(report.SelectedOptionText)) + logger.LogDebug("Message report option selected: {OptionText}", report.SelectedOptionText); + if (!report.Success) + { + failureReason = NormalizeReason(report.Error); + if (LooksLikePeerInvalid(report.Error)) + resolvedCache.Remove(cacheKey); + } + } + } + catch (Exception ex) + { + logger.LogWarning(ex, "Message report task iteration failed (taskId={TaskId}, accountId={AccountId}, message={Message})", host.TaskId, account.Id, messageReference.CanonicalId); + var mapped = AccountTelegramToolsService.MapTelegramException(ex); + failureReason = NormalizeReason(string.IsNullOrWhiteSpace(mapped.details) ? mapped.summary : $"{mapped.summary}:{mapped.details}"); + } + + completed++; + if (!string.IsNullOrWhiteSpace(failureReason)) + { + failed++; + AddFailure(config, account, messageReference.Raw, failureReason!); + } + + config.Canceled = false; + config.Error = null; + config.CompletedAttempts = completed; + config.FailedAttempts = failed; + config.AccountCursor = accountCursor; + config.MessageCursor = messageCursor; + config.CommentCursor = commentCursor; + await host.UpdateProgressAsync(completed, failed, cancellationToken); + await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(config)); + + if (config.MaxReports > 0 && completed >= config.MaxReports) + break; + + if (!await DelayAsync(host, config.DelayMinMs, config.DelayMaxMs, cancellationToken)) + { + config.Canceled = true; + config.Error = null; + config.CompletedAttempts = completed; + config.FailedAttempts = failed; + config.AccountCursor = accountCursor; + config.MessageCursor = messageCursor; + config.CommentCursor = commentCursor; + await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(config)); + return; + } + } + + config.Canceled = false; + config.Error = null; + config.CompletedAttempts = completed; + config.FailedAttempts = failed; + config.AccountCursor = accountCursor; + config.MessageCursor = messageCursor; + config.CommentCursor = commentCursor; + await host.UpdateProgressAsync(completed, failed, cancellationToken); + await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(config)); + } + + private static MessageReportTaskConfig DeserializeConfig(string? rawConfig) + { + var raw = (rawConfig ?? string.Empty).Trim(); + if (raw.Length == 0) + throw new InvalidOperationException("任务配置为空"); + + try + { + return JsonSerializer.Deserialize(raw) + ?? throw new InvalidOperationException("任务配置解析结果为空"); + } + catch (Exception ex) + { + throw new InvalidOperationException($"任务配置 JSON 无效:{ex.Message}"); + } + } + + private static void ValidateAndNormalizeConfig(MessageReportTaskConfig config) + { + config.CategoryIds = MessageReportTaskInputHelper.NormalizeCategoryIds(config.CategoryIds, config.CategoryId); + config.CategoryNames = MessageReportTaskInputHelper.NormalizeCategoryNames(config.CategoryNames, config.CategoryName); + config.MessageReferences = MessageReportTaskInputHelper.NormalizeLines(config.MessageReferences, distinct: true); + config.OptionKeywords = MessageReportTaskInputHelper.NormalizeLines(config.OptionKeywords, distinct: true); + config.Comments = MessageReportTaskInputHelper.NormalizeLines(config.Comments); + config.CommentMode = MessageReportTaskModes.Normalize(config.CommentMode); + config.ReportType = MessageReportTaskReportTypes.Normalize(config.ReportType); + config.DelayMinMs = Math.Clamp(config.DelayMinMs, 0, 600000); + config.DelayMaxMs = Math.Clamp(config.DelayMaxMs, config.DelayMinMs, 600000); + config.MaxReports = Math.Max(0, config.MaxReports); + config.CompletedAttempts = Math.Max(0, config.CompletedAttempts); + config.FailedAttempts = Math.Max(0, config.FailedAttempts); + config.AccountCursor = Math.Max(0, config.AccountCursor); + config.MessageCursor = Math.Max(0, config.MessageCursor); + config.CommentCursor = Math.Max(0, config.CommentCursor); + config.RecentFailures ??= new List(); + + if (config.CategoryIds.Count == 0) + throw new InvalidOperationException("请至少选择一个执行账号分类"); + + if (config.MessageReferences.Count == 0) + throw new InvalidOperationException("请至少填写一个消息引用"); + + foreach (var messageReference in config.MessageReferences) + { + if (!MessageReportTaskInputHelper.TryParseMessageReference(messageReference, out _, out var error)) + throw new InvalidOperationException($"消息引用无效:{messageReference}({error})"); + } + } + + private static int SelectIndex(int count, ref int cursor) + { + if (count <= 0) + return 0; + + var index = NormalizeCursor(cursor, count); + cursor = (index + 1) % count; + return index; + } + + private static int NormalizeCursor(int cursor, int count) + { + if (count <= 0) + return 0; + if (cursor < 0) + return 0; + return cursor % count; + } + + private static async Task DelayAsync(IModuleTaskExecutionHost host, int minDelayMs, int maxDelayMs, CancellationToken cancellationToken) + { + var delayMs = NextDelayMilliseconds(minDelayMs, maxDelayMs); + if (delayMs <= 0) + return true; + + var remaining = delayMs; + while (remaining > 0) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!await host.IsStillRunningAsync(cancellationToken)) + return false; + + var chunk = Math.Min(remaining, 1000); + await Task.Delay(chunk, cancellationToken); + remaining -= chunk; + } + + return true; + } + + private static int NextDelayMilliseconds(int minDelayMs, int maxDelayMs) + { + minDelayMs = Math.Max(0, minDelayMs); + maxDelayMs = Math.Max(minDelayMs, maxDelayMs); + return maxDelayMs <= minDelayMs + ? minDelayMs + : Random.Shared.Next(minDelayMs, maxDelayMs + 1); + } + + private static void AddFailure(MessageReportTaskConfig config, Account account, string messageReference, string reason) + { + config.RecentFailures ??= new List(); + config.RecentFailures.Add(new MessageReportTaskRuntimeFailure + { + TimestampUtc = DateTime.UtcNow, + AccountId = account.Id, + AccountDisplay = BuildAccountDisplayName(account), + MessageReference = messageReference, + Reason = reason + }); + + if (config.RecentFailures.Count > MaxFailureLines) + config.RecentFailures = config.RecentFailures.TakeLast(MaxFailureLines).ToList(); + } + + private static string BuildAccountDisplayName(Account account) + { + return $"#{account.Id} {(string.IsNullOrWhiteSpace(account.DisplayPhone) ? account.Phone : account.DisplayPhone)}"; + } + + private static bool LooksLikePeerInvalid(string? error) + { + var text = (error ?? string.Empty).ToUpperInvariant(); + return text.Contains("PEER_ID_INVALID", StringComparison.Ordinal) + || text.Contains("CHAT_ID_INVALID", StringComparison.Ordinal) + || text.Contains("CHANNEL_INVALID", StringComparison.Ordinal) + || text.Contains("USERNAME_INVALID", StringComparison.Ordinal) + || text.Contains("USERNAME_NOT_OCCUPIED", StringComparison.Ordinal); + } + + private static string NormalizeReason(string? error) + { + return string.IsNullOrWhiteSpace(error) ? "Telegram 请求失败" : error.Trim(); + } + + private static string SerializeIndented(MessageReportTaskConfig config) + { + return JsonSerializer.Serialize(config, new JsonSerializerOptions { WriteIndented = true }); + } +} diff --git a/src/TelegramPanel.Modules.MessageReport/MessageReportTaskModels.cs b/src/TelegramPanel.Modules.MessageReport/MessageReportTaskModels.cs new file mode 100644 index 0000000..4230f54 --- /dev/null +++ b/src/TelegramPanel.Modules.MessageReport/MessageReportTaskModels.cs @@ -0,0 +1,266 @@ +using System.Globalization; +using System.Text.Json.Serialization; + +namespace TelegramPanel.Modules.MessageReport; + +public static class MessageReportTaskTypes +{ + public const string TaskType = "module_message_report"; +} + +public static class MessageReportTaskModes +{ + public const string Random = "random"; + public const string Queue = "queue"; + + public static string Normalize(string? value) + { + return string.Equals((value ?? string.Empty).Trim(), Queue, StringComparison.OrdinalIgnoreCase) + ? Queue + : Random; + } +} + +public static class MessageReportTaskReportTypes +{ + public const string Auto = "auto"; + public const string Spam = "spam"; + public const string Violence = "violence"; + public const string Pornography = "pornography"; + public const string ChildAbuse = "child_abuse"; + public const string Copyright = "copyright"; + public const string PersonalDetails = "personal_details"; + public const string IllegalDrugs = "illegal_drugs"; + public const string Fake = "fake"; + public const string Other = "other"; + + public static string Normalize(string? value) + { + var normalized = (value ?? string.Empty).Trim().ToLowerInvariant(); + return normalized switch + { + Spam or Violence or Pornography or ChildAbuse or Copyright or PersonalDetails or IllegalDrugs or Fake or Other => normalized, + _ => Auto + }; + } +} + +public sealed class MessageReportTaskConfig +{ + [JsonPropertyName("category_id")] + public int CategoryId { get; set; } + + [JsonPropertyName("category_name")] + public string? CategoryName { get; set; } + + [JsonPropertyName("category_ids")] + public List CategoryIds { get; set; } = new(); + + [JsonPropertyName("category_names")] + public List CategoryNames { get; set; } = new(); + + [JsonPropertyName("message_references")] + public List MessageReferences { get; set; } = new(); + + [JsonPropertyName("delay_min_ms")] + public int DelayMinMs { get; set; } = 15000; + + [JsonPropertyName("delay_max_ms")] + public int DelayMaxMs { get; set; } = 45000; + + [JsonPropertyName("max_reports")] + public int MaxReports { get; set; } + + [JsonPropertyName("report_type")] + public string ReportType { get; set; } = MessageReportTaskReportTypes.Spam; + + [JsonPropertyName("option_keywords")] + public List OptionKeywords { get; set; } = new(); + + [JsonPropertyName("comments")] + public List Comments { get; set; } = new(); + + [JsonPropertyName("comment_mode")] + public string CommentMode { get; set; } = MessageReportTaskModes.Queue; + + [JsonPropertyName("completed_attempts")] + public int CompletedAttempts { get; set; } + + [JsonPropertyName("failed_attempts")] + public int FailedAttempts { get; set; } + + [JsonPropertyName("account_cursor")] + public int AccountCursor { get; set; } + + [JsonPropertyName("message_cursor")] + public int MessageCursor { get; set; } + + [JsonPropertyName("comment_cursor")] + public int CommentCursor { get; set; } + + [JsonPropertyName("canceled")] + public bool Canceled { get; set; } + + [JsonPropertyName("error")] + public string? Error { get; set; } + + [JsonPropertyName("recent_failures")] + public List RecentFailures { get; set; } = new(); +} + +public sealed class MessageReportTaskRuntimeFailure +{ + [JsonPropertyName("timestamp_utc")] + public DateTime TimestampUtc { get; set; } = DateTime.UtcNow; + + [JsonPropertyName("account_id")] + public int AccountId { get; set; } + + [JsonPropertyName("account_display")] + public string AccountDisplay { get; set; } = string.Empty; + + [JsonPropertyName("message_reference")] + public string MessageReference { get; set; } = string.Empty; + + [JsonPropertyName("reason")] + public string Reason { get; set; } = string.Empty; +} + +public sealed record ParsedMessageReference(string Raw, string Target, int MessageId, string CanonicalId); + +public static class MessageReportTaskInputHelper +{ + public static List NormalizeCategoryIds(IEnumerable? values, int fallback) + { + var ids = (values ?? Array.Empty()) + .Where(x => x > 0) + .Distinct() + .ToList(); + + if (ids.Count == 0 && fallback > 0) + ids.Add(fallback); + + return ids; + } + + public static List NormalizeCategoryNames(IEnumerable? values, string? fallback) + { + var names = NormalizeLines(values, distinct: true); + var fallbackName = (fallback ?? string.Empty).Trim(); + if (names.Count == 0 && fallbackName.Length > 0) + names.Add(fallbackName); + return names; + } + + public static List NormalizeLines(IEnumerable? values, bool distinct = false) + { + var lines = (values ?? Array.Empty()) + .Select(x => (x ?? string.Empty).Trim()) + .Where(x => x.Length > 0) + .ToList(); + + return distinct + ? lines.Distinct(StringComparer.OrdinalIgnoreCase).ToList() + : lines; + } + + public static List ParseMultilineText(string? text, bool distinct = false) + { + if (string.IsNullOrWhiteSpace(text)) + return new List(); + + return NormalizeLines( + text.Replace("\r", string.Empty).Split('\n', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries), + distinct); + } + + public static bool TryParseMessageReference(string? raw, out ParsedMessageReference? parsed, out string? error) + { + parsed = null; + error = null; + + var value = (raw ?? string.Empty).Trim(); + if (value.Length == 0) + { + error = "消息引用不能为空"; + return false; + } + + value = NormalizeTelegramPath(value); + if (value.Length == 0) + { + error = "消息引用格式无效"; + return false; + } + + var parts = value.Split('/', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + string target; + string messageIdText; + + if (parts.Length >= 3 && string.Equals(parts[0], "c", StringComparison.OrdinalIgnoreCase)) + { + if (!long.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var internalChatId) || internalChatId <= 0) + { + error = "t.me/c 链接中的频道 ID 无效"; + return false; + } + + target = $"-100{internalChatId}"; + messageIdText = parts[2]; + } + else if (parts.Length >= 2) + { + target = parts[0]; + messageIdText = parts[1]; + } + else + { + error = "消息引用格式无效,应类似 username/123 或 t.me/c/1234567890/123"; + return false; + } + + target = target.Trim().TrimStart('@'); + if (target.Length == 0) + { + error = "消息引用缺少目标"; + return false; + } + + if (!int.TryParse(messageIdText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var messageId) || messageId <= 0) + { + error = "消息 ID 无效,必须是正整数"; + return false; + } + + parsed = new ParsedMessageReference((raw ?? string.Empty).Trim(), target, messageId, $"{target}/{messageId}"); + return true; + } + + private static string NormalizeTelegramPath(string value) + { + var raw = value.Trim(); + if (Uri.TryCreate(raw, UriKind.Absolute, out var absolute)) + { + var host = (absolute.Host ?? string.Empty).Trim().ToLowerInvariant(); + if (host is not ("t.me" or "www.t.me" or "telegram.me" or "www.telegram.me")) + return string.Empty; + + raw = absolute.PathAndQuery; + } + else if (raw.StartsWith("t.me/", StringComparison.OrdinalIgnoreCase) + || raw.StartsWith("telegram.me/", StringComparison.OrdinalIgnoreCase)) + { + raw = raw[(raw.IndexOf('/') + 1)..]; + } + + var questionMarkIndex = raw.IndexOf('?'); + if (questionMarkIndex >= 0) + raw = raw[..questionMarkIndex]; + + raw = raw.Trim().Trim('/').Replace('\\', '/'); + if (raw.StartsWith("s/", StringComparison.OrdinalIgnoreCase)) + raw = raw[2..]; + + return raw.Trim('/'); + } +} diff --git a/src/TelegramPanel.Modules.MessageReport/MessageReportTaskModule.cs b/src/TelegramPanel.Modules.MessageReport/MessageReportTaskModule.cs new file mode 100644 index 0000000..3195629 --- /dev/null +++ b/src/TelegramPanel.Modules.MessageReport/MessageReportTaskModule.cs @@ -0,0 +1,56 @@ +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using MudBlazor; +using TelegramPanel.Modules; + +namespace TelegramPanel.Modules.MessageReport; + +public sealed class MessageReportTaskModule : ITelegramPanelModule, IModuleTaskProvider +{ + public ModuleManifest Manifest { get; } = new() + { + Id = "task.message-report", + Name = "任务:消息举报", + Version = "1.0.0", + Host = new HostCompatibility { Min = "0.0.0", Max = "2.0.0" }, + Entry = new ModuleEntryPoint + { + Assembly = "TelegramPanel.Modules.MessageReport.dll", + Type = typeof(MessageReportTaskModule).FullName ?? string.Empty + } + }; + + public void ConfigureServices(IServiceCollection services, ModuleHostContext context) + { + services.AddScoped(); + services.AddScoped(); + services.AddSingleton(); + } + + public void MapEndpoints(IEndpointRouteBuilder endpoints, ModuleHostContext context) + { + } + + public IEnumerable GetTasks(ModuleHostContext context) + { + yield return new ModuleTaskDefinition + { + Category = "user", + TaskType = MessageReportTaskTypes.TaskType, + DisplayName = "消息举报(多账号)", + Description = "按账号分类持续举报指定消息,支持间隔控制、达到次数后停止,也支持 Cron 计划触发。", + Icon = Icons.Material.Filled.Warning, + EditorComponentType = typeof(MessageReportTaskEditor).AssemblyQualifiedName ?? string.Empty, + TaskCenter = new ModuleTaskCenterCapabilities + { + CanPause = true, + CanResume = true, + CanEdit = true, + CanRerun = true, + EditComponentType = typeof(MessageReportTaskEditor).AssemblyQualifiedName ?? string.Empty, + AutoPauseBeforeEdit = true + }, + Order = 160 + }; + } +} diff --git a/src/TelegramPanel.Modules.MessageReport/MessageReportTaskRerunBuilder.cs b/src/TelegramPanel.Modules.MessageReport/MessageReportTaskRerunBuilder.cs new file mode 100644 index 0000000..6a47ad2 --- /dev/null +++ b/src/TelegramPanel.Modules.MessageReport/MessageReportTaskRerunBuilder.cs @@ -0,0 +1,75 @@ +using System.Text.Json; +using TelegramPanel.Modules; + +namespace TelegramPanel.Modules.MessageReport; + +public sealed class MessageReportTaskRerunBuilder : IModuleTaskRerunBuilder +{ + public string TaskType => MessageReportTaskTypes.TaskType; + + public ModuleTaskCreateRequest Build(ModuleTaskSnapshot task) + { + var rerunConfig = BuildRerunConfig(task.Config); + var total = rerunConfig.MaxReports > 0 ? rerunConfig.MaxReports : 0; + + return new ModuleTaskCreateRequest + { + TaskType = MessageReportTaskTypes.TaskType, + Total = total, + Config = JsonSerializer.Serialize(rerunConfig, new JsonSerializerOptions { WriteIndented = true }) + }; + } + + private static MessageReportTaskConfig BuildRerunConfig(string? rawConfig) + { + var raw = (rawConfig ?? string.Empty).Trim(); + if (raw.Length == 0) + throw new InvalidOperationException("任务配置为空,无法重新运行"); + + MessageReportTaskConfig cfg; + try + { + cfg = JsonSerializer.Deserialize(raw) + ?? throw new InvalidOperationException("任务配置解析结果为空"); + } + catch (Exception ex) + { + throw new InvalidOperationException($"任务配置 JSON 无效:{ex.Message}"); + } + + cfg.CategoryIds = MessageReportTaskInputHelper.NormalizeCategoryIds(cfg.CategoryIds, cfg.CategoryId); + if (cfg.CategoryIds.Count == 0) + throw new InvalidOperationException("任务缺少账号分类,无法重新运行"); + + cfg.CategoryNames = MessageReportTaskInputHelper.NormalizeCategoryNames(cfg.CategoryNames, cfg.CategoryName); + cfg.CategoryId = cfg.CategoryIds[0]; + cfg.CategoryName = cfg.CategoryNames.FirstOrDefault() ?? cfg.CategoryName; + cfg.MessageReferences = MessageReportTaskInputHelper.NormalizeLines(cfg.MessageReferences, distinct: true); + cfg.OptionKeywords = MessageReportTaskInputHelper.NormalizeLines(cfg.OptionKeywords, distinct: true); + cfg.Comments = MessageReportTaskInputHelper.NormalizeLines(cfg.Comments); + cfg.ReportType = MessageReportTaskReportTypes.Normalize(cfg.ReportType); + cfg.CommentMode = MessageReportTaskModes.Normalize(cfg.CommentMode); + + foreach (var messageReference in cfg.MessageReferences) + { + if (!MessageReportTaskInputHelper.TryParseMessageReference(messageReference, out _, out var error)) + throw new InvalidOperationException($"消息引用无效:{messageReference}({error})"); + } + + if (cfg.MessageReferences.Count == 0) + throw new InvalidOperationException("任务缺少消息引用,无法重新运行"); + + cfg.DelayMinMs = Math.Clamp(cfg.DelayMinMs, 0, 600000); + cfg.DelayMaxMs = Math.Clamp(cfg.DelayMaxMs, cfg.DelayMinMs, 600000); + cfg.MaxReports = Math.Max(0, cfg.MaxReports); + cfg.CompletedAttempts = 0; + cfg.FailedAttempts = 0; + cfg.AccountCursor = 0; + cfg.MessageCursor = 0; + cfg.CommentCursor = 0; + cfg.Canceled = false; + cfg.Error = null; + cfg.RecentFailures = new List(); + return cfg; + } +} diff --git a/src/TelegramPanel.Modules.MessageReport/MessageReportTaskTelegramService.cs b/src/TelegramPanel.Modules.MessageReport/MessageReportTaskTelegramService.cs new file mode 100644 index 0000000..0739c3dd --- /dev/null +++ b/src/TelegramPanel.Modules.MessageReport/MessageReportTaskTelegramService.cs @@ -0,0 +1,419 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using TelegramPanel.Core.Interfaces; +using TelegramPanel.Core.Services; +using TelegramPanel.Core.Services.Telegram; +using TelegramPanel.Data.Entities; +using TL; +using WTelegram; + +namespace TelegramPanel.Modules.MessageReport; + +public sealed class MessageReportTaskTelegramService +{ + private static readonly IReadOnlyDictionary ReportTypeKeywords = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [MessageReportTaskReportTypes.Spam] = ["spam", "垃圾", "广告"], + [MessageReportTaskReportTypes.Violence] = ["violence", "violent", "暴力"], + [MessageReportTaskReportTypes.Pornography] = ["porn", "pornography", "sexual", "nsfw", "色情", "成人"], + [MessageReportTaskReportTypes.ChildAbuse] = ["child", "minor", "underage", "儿童", "未成年", "虐待", "剥削"], + [MessageReportTaskReportTypes.Copyright] = ["copyright", "版权", "侵权", "盗版"], + [MessageReportTaskReportTypes.PersonalDetails] = ["personal", "private", "privacy", "隐私", "个人信息", "泄露"], + [MessageReportTaskReportTypes.IllegalDrugs] = ["drug", "drugs", "毒品", "违禁药"], + [MessageReportTaskReportTypes.Fake] = ["fake", "fraud", "scam", "冒充", "诈骗", "虚假"], + [MessageReportTaskReportTypes.Other] = ["other", "其他"] + }; + + private readonly AccountManagementService _accountManagement; + private readonly ITelegramClientPool _clientPool; + private readonly AccountTelegramToolsService _accountTools; + private readonly IConfiguration _configuration; + private readonly ILogger _logger; + + public MessageReportTaskTelegramService( + AccountManagementService accountManagement, + ITelegramClientPool clientPool, + AccountTelegramToolsService accountTools, + IConfiguration configuration, + ILogger logger) + { + _accountManagement = accountManagement; + _clientPool = clientPool; + _accountTools = accountTools; + _configuration = configuration; + _logger = logger; + } + + public async Task<(bool Success, string? Error, ResolvedMessageTarget? Target)> ResolveMessageReferenceAsync( + int accountId, + string rawReference, + CancellationToken cancellationToken) + { + if (!MessageReportTaskInputHelper.TryParseMessageReference(rawReference, out var parsed, out var parseError) || parsed == null) + return (false, parseError ?? "消息引用无效", null); + + var resolved = await _accountTools.ResolveChatTargetAsync(accountId, parsed.Target, cancellationToken); + if (!resolved.Success || resolved.Target == null) + return (false, NormalizeError(resolved.Error), null); + + return (true, null, new ResolvedMessageTarget(parsed, resolved.Target)); + } + + public async Task ReportMessageAsync( + int accountId, + ResolvedMessageTarget target, + MessageReportTaskConfig config, + int commentCursor, + CancellationToken cancellationToken) + { + try + { + var client = await GetOrCreateConnectedClientAsync(accountId, cancellationToken); + var option = Array.Empty(); + var comment = string.Empty; + string? selectedOptionText = null; + var nextCommentCursor = commentCursor; + + for (var step = 0; step < 6; step++) + { + cancellationToken.ThrowIfCancellationRequested(); + + var result = await ExecuteTelegramRequestAsync( + accountId, + "举报消息", + () => client.Messages_Report(target.ChatTarget.Peer, [target.Reference.MessageId], option, comment), + cancellationToken, + resetClientOnTimeout: true); + + switch (result) + { + case ReportResultReported: + return ReportMessageResult.Ok(nextCommentCursor, selectedOptionText); + + case ReportResultChooseOption chooseOption: + { + var selected = SelectOption(chooseOption.options, config.ReportType, config.OptionKeywords, out var matchError); + if (selected == null) + return ReportMessageResult.Fail(matchError ?? "无法匹配 Telegram 返回的举报菜单", nextCommentCursor); + + option = selected.option ?? Array.Empty(); + comment = string.Empty; + selectedOptionText = selected.text; + break; + } + + case ReportResultAddComment addComment: + { + option = addComment.option ?? option; + var selectedComment = PickComment(config.Comments, config.CommentMode, nextCommentCursor, out nextCommentCursor); + if (string.IsNullOrWhiteSpace(selectedComment)) + { + if (!addComment.flags.HasFlag(ReportResultAddComment.Flags.optional)) + return ReportMessageResult.Fail("当前举报步骤要求填写举报说明,但任务配置未提供可用文案", nextCommentCursor); + + comment = string.Empty; + } + else + { + comment = selectedComment; + } + + break; + } + + default: + return ReportMessageResult.Fail($"收到未知举报结果类型:{result.GetType().Name}", nextCommentCursor); + } + } + + return ReportMessageResult.Fail("举报流程超过允许的最大步骤数", commentCursor); + } + catch (Exception ex) + { + var mapped = AccountTelegramToolsService.MapTelegramException(ex); + _logger.LogWarning(ex, "Message report failed for account {AccountId}, target {Target}", accountId, target.Reference.CanonicalId); + return ReportMessageResult.Fail(string.IsNullOrWhiteSpace(mapped.details) ? mapped.summary : $"{mapped.summary}:{mapped.details}", commentCursor); + } + } + + private async Task GetOrCreateConnectedClientAsync(int accountId, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + var existing = _clientPool.GetClient(accountId); + if (existing?.User != null) + return existing; + + var account = await _accountManagement.GetAccountAsync(accountId) + ?? throw new InvalidOperationException($"账号不存在:{accountId}"); + + var apiId = ResolveApiId(account); + var apiHash = ResolveApiHash(account); + var sessionKey = ResolveSessionKey(account, apiHash); + + if (string.IsNullOrWhiteSpace(account.SessionPath)) + throw new InvalidOperationException("账号缺少 SessionPath,无法创建 Telegram 客户端"); + + var absoluteSessionPath = Path.GetFullPath(account.SessionPath); + if (File.Exists(absoluteSessionPath) && SessionDataConverter.LooksLikeSqliteSession(absoluteSessionPath)) + { + var converted = await SessionDataConverter.TryConvertSqliteSessionFromJsonAsync( + phone: account.Phone, + apiId: account.ApiId, + apiHash: account.ApiHash, + sqliteSessionPath: absoluteSessionPath, + logger: _logger); + + if (!converted.Ok) + { + throw new InvalidOperationException( + $"该账号的 Session 文件为 SQLite 格式:{account.SessionPath},无法自动转换为可用 session。原因:{converted.Reason}。" + + "建议:重新导入包含 session_string 的 json,或到【账号-手机号登录】重新登录生成新的 sessions/*.session。" + ); + } + } + + await _clientPool.RemoveClientAsync(accountId); + + var client = await _clientPool.GetOrCreateClientAsync( + accountId: accountId, + apiId: apiId, + apiHash: apiHash, + sessionPath: account.SessionPath, + sessionKey: sessionKey, + phoneNumber: account.Phone, + userId: account.UserId > 0 ? account.UserId : null); + + try + { + await ExecuteTelegramRequestAsync( + accountId, + "连接 Telegram", + () => client.ConnectAsync(), + cancellationToken, + resetClientOnTimeout: true); + + if (client.User == null && (client.UserId != 0 || account.UserId != 0)) + { + await ExecuteTelegramRequestAsync( + accountId, + "恢复 Telegram 登录状态", + () => client.LoginUserIfNeeded(reloginOnFailedResume: false), + cancellationToken, + resetClientOnTimeout: true); + } + } + catch (Exception ex) + { + if (LooksLikeSessionApiMismatchOrCorrupted(ex)) + { + throw new InvalidOperationException( + "该账号的 Session 文件无法解析(通常是 ApiId/ApiHash 与生成 session 时不一致,或 session 文件已损坏)。" + + "请到【账号-手机号登录】重新登录生成新的 session 后再试。", + ex); + } + + throw new InvalidOperationException($"Telegram 会话加载失败:{ex.Message}", ex); + } + + if (client.User == null) + throw new InvalidOperationException("账号未登录或 session 已失效,请重新登录生成新的 session"); + + return client; + } + + private async Task ExecuteTelegramRequestAsync( + int accountId, + string operation, + Func action, + CancellationToken cancellationToken, + bool resetClientOnTimeout = false) + { + try + { + await action().WaitAsync(GetTelegramRequestTimeout(), cancellationToken); + } + catch (TimeoutException) + { + if (resetClientOnTimeout) + await _clientPool.RemoveClientAsync(accountId); + + throw new TimeoutException($"{operation} 超时"); + } + } + + private async Task ExecuteTelegramRequestAsync( + int accountId, + string operation, + Func> action, + CancellationToken cancellationToken, + bool resetClientOnTimeout = false) + { + try + { + return await action().WaitAsync(GetTelegramRequestTimeout(), cancellationToken); + } + catch (TimeoutException) + { + if (resetClientOnTimeout) + await _clientPool.RemoveClientAsync(accountId); + + throw new TimeoutException($"{operation} 超时"); + } + } + + private TimeSpan GetTelegramRequestTimeout() + { + if (int.TryParse(_configuration["Telegram:RequestTimeoutSeconds"], out var seconds) && seconds >= 10 && seconds <= 600) + return TimeSpan.FromSeconds(seconds); + + return TimeSpan.FromSeconds(45); + } + + private static MessageReportOption? SelectOption( + IEnumerable? options, + string reportType, + IEnumerable? customKeywords, + out string? error) + { + error = null; + var optionList = (options ?? Array.Empty()).ToList(); + if (optionList.Count == 0) + { + error = "Telegram 未返回可用的举报选项"; + return null; + } + + var keywords = BuildKeywords(reportType, customKeywords).ToList(); + if (keywords.Count > 0) + { + foreach (var candidate in optionList) + { + var normalizedText = NormalizeForMatch(candidate.text); + if (keywords.Any(keyword => normalizedText.Contains(keyword, StringComparison.Ordinal))) + return candidate; + } + } + + if (string.Equals(reportType, MessageReportTaskReportTypes.Auto, StringComparison.OrdinalIgnoreCase)) + return optionList[0]; + + if (optionList.Count == 1) + return optionList[0]; + + error = "未能根据当前配置匹配 Telegram 返回的举报选项:" + + string.Join(" / ", optionList.Select(x => string.IsNullOrWhiteSpace(x.text) ? "<空标题>" : x.text.Trim())); + return null; + } + + private static IEnumerable BuildKeywords(string reportType, IEnumerable? customKeywords) + { + var normalizedReportType = MessageReportTaskReportTypes.Normalize(reportType); + if (ReportTypeKeywords.TryGetValue(normalizedReportType, out var defaultKeywords)) + { + foreach (var keyword in defaultKeywords) + { + var normalized = NormalizeForMatch(keyword); + if (normalized.Length > 0) + yield return normalized; + } + } + + foreach (var keyword in MessageReportTaskInputHelper.NormalizeLines(customKeywords, distinct: true)) + { + var normalized = NormalizeForMatch(keyword); + if (normalized.Length > 0) + yield return normalized; + } + } + + private static string NormalizeForMatch(string? value) + { + return new string((value ?? string.Empty) + .Trim() + .ToLowerInvariant() + .Where(ch => !char.IsWhiteSpace(ch) && ch is not '-' and not '_' and not ':' and not ':') + .ToArray()); + } + + private static string? PickComment(IReadOnlyList? comments, string mode, int cursor, out int nextCursor) + { + var normalized = MessageReportTaskInputHelper.NormalizeLines(comments, distinct: false); + if (normalized.Count == 0) + { + nextCursor = cursor; + return null; + } + + if (string.Equals(MessageReportTaskModes.Normalize(mode), MessageReportTaskModes.Queue, StringComparison.OrdinalIgnoreCase)) + { + var index = NormalizeCursor(cursor, normalized.Count); + nextCursor = normalized.Count == 0 ? 0 : (index + 1) % normalized.Count; + return normalized[index]; + } + + nextCursor = cursor; + return normalized[Random.Shared.Next(normalized.Count)]; + } + + private static int NormalizeCursor(int cursor, int count) + { + if (count <= 0) + return 0; + + if (cursor < 0) + return 0; + + return cursor % count; + } + + private static string NormalizeError(string? error) + { + return string.IsNullOrWhiteSpace(error) ? "Telegram 请求失败" : error.Trim(); + } + + private int ResolveApiId(Account account) + { + if (int.TryParse(_configuration["Telegram:ApiId"], out var globalApiId) && globalApiId > 0) + return globalApiId; + if (account.ApiId > 0) + return account.ApiId; + throw new InvalidOperationException("未配置全局 ApiId,且账号缺少 ApiId"); + } + + private string ResolveApiHash(Account account) + { + var global = _configuration["Telegram:ApiHash"]; + if (!string.IsNullOrWhiteSpace(global)) + return global.Trim(); + if (!string.IsNullOrWhiteSpace(account.ApiHash)) + return account.ApiHash.Trim(); + throw new InvalidOperationException("未配置全局 ApiHash,且账号缺少 ApiHash"); + } + + private static string ResolveSessionKey(Account account, string apiHash) + { + return !string.IsNullOrWhiteSpace(account.ApiHash) ? account.ApiHash.Trim() : apiHash.Trim(); + } + + private static bool LooksLikeSessionApiMismatchOrCorrupted(Exception ex) + { + var message = ex.Message ?? string.Empty; + return message.Contains("Can't read session block", StringComparison.OrdinalIgnoreCase) + || message.Contains("Use the correct api_hash", StringComparison.OrdinalIgnoreCase) + || message.Contains("Use the correct api_hash/id/key", StringComparison.OrdinalIgnoreCase); + } +} + +public sealed record ResolvedMessageTarget( + ParsedMessageReference Reference, + AccountTelegramToolsService.ResolvedChatTarget ChatTarget); + +public sealed record ReportMessageResult(bool Success, string? Error, int NextCommentCursor, string? SelectedOptionText) +{ + public static ReportMessageResult Ok(int nextCommentCursor, string? selectedOptionText) + => new(true, null, nextCommentCursor, selectedOptionText); + + public static ReportMessageResult Fail(string error, int nextCommentCursor) + => new(false, error, nextCommentCursor, null); +} diff --git a/src/TelegramPanel.Modules.MessageReport/TelegramPanel.Modules.MessageReport.csproj b/src/TelegramPanel.Modules.MessageReport/TelegramPanel.Modules.MessageReport.csproj new file mode 100644 index 0000000..f1c4730 --- /dev/null +++ b/src/TelegramPanel.Modules.MessageReport/TelegramPanel.Modules.MessageReport.csproj @@ -0,0 +1,23 @@ + + + net8.0 + enable + enable + TelegramPanel.Modules.MessageReport + TelegramPanel.Modules.MessageReport + + + + + + + + + + + + + + + + diff --git a/src/TelegramPanel.Modules.MessageReport/_Imports.razor b/src/TelegramPanel.Modules.MessageReport/_Imports.razor new file mode 100644 index 0000000..51a4aef --- /dev/null +++ b/src/TelegramPanel.Modules.MessageReport/_Imports.razor @@ -0,0 +1,4 @@ +@using Microsoft.AspNetCore.Components +@using MudBlazor +@using TelegramPanel.Modules +@using TelegramPanel.Modules.MessageReport diff --git a/src/TelegramPanel.Modules.MessageReport/manifest.json b/src/TelegramPanel.Modules.MessageReport/manifest.json new file mode 100644 index 0000000..5fb95d4 --- /dev/null +++ b/src/TelegramPanel.Modules.MessageReport/manifest.json @@ -0,0 +1,11 @@ +{ + "id": "task.message-report", + "name": "任务:消息举报", + "version": "1.0.0", + "host": { "min": "0.0.0", "max": "2.0.0" }, + "dependencies": [], + "entry": { + "assembly": "TelegramPanel.Modules.MessageReport.dll", + "type": "TelegramPanel.Modules.MessageReport.MessageReportTaskModule" + } +} diff --git a/src/TelegramPanel.Modules.MessageReportTask/Components/Dialogs/MessageReportTaskEditor.razor b/src/TelegramPanel.Modules.MessageReportTask/Components/Dialogs/MessageReportTaskEditor.razor new file mode 100644 index 0000000..9a2c644 --- /dev/null +++ b/src/TelegramPanel.Modules.MessageReportTask/Components/Dialogs/MessageReportTaskEditor.razor @@ -0,0 +1,315 @@ +@using System.Text.Json +@using TelegramPanel.Modules.MessageReportTask.Services +@inject AccountCategoryManagementService CategoryManagement + + + + 该任务会按账号分类轮流对指定消息链接执行举报。最多举报次数为 0 表示不设上限,直到所有可用账号-消息组合处理完成或你在任务中心手动停止。 + + + + Telegram 的消息举报是动态选项流程。这里的“举报类型”本质上是匹配 Telegram 返回的选项文字;如果匹配不到,任务会记失败,但不会盲选错误类型。 + + + @if (loading) + { + + 正在加载账号分类... + } + else + { + + @foreach (var c in categories) + { + @c.Name + } + + + + + + + + + + + + + + + + + + 垃圾 / 骚扰 + 暴力 / 威胁 + 色情 / 淫秽 + 儿童虐待 + 版权侵权 + 违禁药物 + 隐私 / 个人信息 + 其他 + 直接选第一个选项 + 自定义关键词 + + + + + + } + + +@code { + [Parameter] public ModuleTaskDraft Draft { get; set; } + [Parameter] public EventCallback DraftChanged { get; set; } + [Parameter] public string? InitialConfigJson { get; set; } + + private List categories = new(); + private bool loading = true; + private bool initializedFromConfig; + + private IEnumerable selectedCategoryIds = Array.Empty(); + private string messageLinksText = string.Empty; + private int delayMinSeconds = 15; + private int delayMaxSeconds = 45; + private int maxReports; + private string reportPreset = MessageReportTaskPresets.Spam; + private string customKeywordsText = string.Empty; + private string commentText = string.Empty; + + protected override async Task OnInitializedAsync() + { + ApplyInitialConfigIfNeeded(); + + try + { + categories = (await CategoryManagement.GetAllCategoriesAsync()) + .Where(x => !x.ExcludeFromOperations) + .OrderBy(x => x.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (!selectedCategoryIds.Any()) + { + var firstCategoryId = categories.FirstOrDefault()?.Id ?? 0; + selectedCategoryIds = firstCategoryId > 0 ? new[] { firstCategoryId } : Array.Empty(); + } + } + finally + { + loading = false; + await PushDraftAsync(); + } + } + + private void ApplyInitialConfigIfNeeded() + { + if (initializedFromConfig) + return; + + initializedFromConfig = true; + + var raw = (InitialConfigJson ?? string.Empty).Trim(); + if (raw.Length == 0) + return; + + try + { + var cfg = JsonSerializer.Deserialize(raw); + if (cfg == null) + return; + + var categoryIds = NormalizeCategoryIds(cfg.CategoryIds, cfg.CategoryId); + if (categoryIds.Count > 0) + selectedCategoryIds = categoryIds; + + messageLinksText = string.Join(Environment.NewLine, cfg.MessageLinks ?? new List()); + delayMinSeconds = ConvertMillisecondsToSeconds(cfg.DelayMinMs); + delayMaxSeconds = ConvertMillisecondsToSeconds(cfg.DelayMaxMs); + maxReports = Math.Max(0, cfg.MaxReports); + reportPreset = NormalizePreset(cfg.ReportPreset); + customKeywordsText = string.Join(Environment.NewLine, cfg.OptionKeywords ?? new List()); + commentText = (cfg.Comment ?? string.Empty).Trim(); + } + catch + { + } + } + + private async Task OnSelectedCategoriesChanged(IEnumerable values) + { + selectedCategoryIds = NormalizeCategoryIds(values, 0); + await OnInputsChanged(); + } + + private async Task OnInputsChanged() + { + await PushDraftAsync(); + } + + private async Task PushDraftAsync() + { + var links = ParseLines(messageLinksText, distinct: true); + var selectedIds = NormalizeCategoryIds(selectedCategoryIds, 0); + var selectedCategories = categories.Where(x => selectedIds.Contains(x.Id)).ToList(); + var keywords = ParseLines(customKeywordsText, distinct: true); + var normalizedPreset = NormalizePreset(reportPreset); + var comment = NormalizeNullableText(commentText); + + if (loading) + { + await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "账号分类加载中")); + return; + } + + if (categories.Count == 0) + { + await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "没有可用账号分类(或都被标记为排除操作)")); + return; + } + + if (selectedCategories.Count == 0) + { + await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "请至少选择一个执行账号分类")); + return; + } + + if (links.Count == 0) + { + await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "请至少填写一条消息链接")); + return; + } + + if (delayMinSeconds < 0 || delayMaxSeconds < 0) + { + await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "间隔不能为负数")); + return; + } + + if (delayMaxSeconds < delayMinSeconds) + { + await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "最大间隔不能小于最小间隔")); + return; + } + + if (maxReports < 0) + { + await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "最多举报次数不能小于 0")); + return; + } + + if (normalizedPreset == MessageReportTaskPresets.Custom && keywords.Count == 0) + { + await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "选择“自定义关键词”时,至少填写一个关键词")); + return; + } + + var categoryNames = selectedCategories.Select(x => x.Name).ToList(); + var cfg = new MessageReportTaskConfig + { + CategoryId = selectedCategories[0].Id, + CategoryName = selectedCategories[0].Name, + CategoryIds = selectedCategories.Select(x => x.Id).ToList(), + CategoryNames = categoryNames, + MessageLinks = links, + DelayMinMs = delayMinSeconds * 1000, + DelayMaxMs = delayMaxSeconds * 1000, + MaxReports = maxReports, + ReportPreset = normalizedPreset, + OptionKeywords = keywords, + Comment = comment + }; + + var total = maxReports > 0 ? maxReports : 0; + var json = JsonSerializer.Serialize(cfg, new JsonSerializerOptions { WriteIndented = true }); + await DraftChanged.InvokeAsync(new ModuleTaskDraft(total, json, true, null)); + } + + private static List NormalizeCategoryIds(IEnumerable? values, int fallback) + { + var ids = (values ?? Array.Empty()) + .Where(x => x > 0) + .Distinct() + .ToList(); + + if (ids.Count == 0 && fallback > 0) + ids.Add(fallback); + + return ids; + } + + private static int ConvertMillisecondsToSeconds(int value) + { + if (value <= 0) + return 0; + + return Math.Max(1, (int)Math.Ceiling(value / 1000d)); + } + + private static string NormalizePreset(string? value) + { + var preset = (value ?? string.Empty).Trim().ToLowerInvariant(); + return preset switch + { + MessageReportTaskPresets.Spam => MessageReportTaskPresets.Spam, + MessageReportTaskPresets.Violence => MessageReportTaskPresets.Violence, + MessageReportTaskPresets.Pornography => MessageReportTaskPresets.Pornography, + MessageReportTaskPresets.ChildAbuse => MessageReportTaskPresets.ChildAbuse, + MessageReportTaskPresets.Copyright => MessageReportTaskPresets.Copyright, + MessageReportTaskPresets.IllegalDrugs => MessageReportTaskPresets.IllegalDrugs, + MessageReportTaskPresets.PersonalDetails => MessageReportTaskPresets.PersonalDetails, + MessageReportTaskPresets.Other => MessageReportTaskPresets.Other, + MessageReportTaskPresets.FirstAvailable => MessageReportTaskPresets.FirstAvailable, + MessageReportTaskPresets.Custom => MessageReportTaskPresets.Custom, + _ => MessageReportTaskPresets.Spam + }; + } + + private static List ParseLines(string? text, bool distinct) + { + if (string.IsNullOrWhiteSpace(text)) + return new List(); + + var values = text + .Split(new[] { "\r\n", "\n", "\r" }, StringSplitOptions.RemoveEmptyEntries) + .Select(x => (x ?? string.Empty).Trim()) + .Where(x => x.Length > 0); + + return distinct + ? values.Distinct(StringComparer.OrdinalIgnoreCase).ToList() + : values.ToList(); + } + + private static string? NormalizeNullableText(string? value) + { + var text = (value ?? string.Empty).Trim(); + return text.Length == 0 ? null : text; + } +} diff --git a/src/TelegramPanel.Modules.MessageReportTask/MessageReportTaskModule.cs b/src/TelegramPanel.Modules.MessageReportTask/MessageReportTaskModule.cs new file mode 100644 index 0000000..6c611e4 --- /dev/null +++ b/src/TelegramPanel.Modules.MessageReportTask/MessageReportTaskModule.cs @@ -0,0 +1,58 @@ +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using MudBlazor; +using TelegramPanel.Modules; +using TelegramPanel.Modules.MessageReportTask.Components.Dialogs; +using TelegramPanel.Modules.MessageReportTask.Services; + +namespace TelegramPanel.Modules.MessageReportTask; + +public sealed class MessageReportTaskModule : ITelegramPanelModule, IModuleTaskProvider +{ + public ModuleManifest Manifest { get; } = new() + { + Id = MessageReportTaskConstants.ModuleId, + Name = "任务:消息举报", + Version = "1.0.0", + Host = new HostCompatibility { Min = "1.31.6", Max = "2.0.0" }, + Entry = new ModuleEntryPoint + { + Assembly = "TelegramPanel.Modules.MessageReportTask.dll", + Type = typeof(MessageReportTaskModule).FullName! + } + }; + + public void ConfigureServices(IServiceCollection services, ModuleHostContext context) + { + services.AddScoped(); + services.AddScoped(); + services.AddSingleton(); + } + + public void MapEndpoints(IEndpointRouteBuilder endpoints, ModuleHostContext context) + { + } + + public IEnumerable GetTasks(ModuleHostContext context) + { + yield return new ModuleTaskDefinition + { + Category = "user", + TaskType = MessageReportTaskConstants.TaskType, + DisplayName = "消息举报(多账号)", + Description = "按账号分类对指定消息链接执行举报,支持间隔、Cron 计划和数量上限。", + Icon = Icons.Material.Filled.Flag, + EditorComponentType = typeof(MessageReportTaskEditor).AssemblyQualifiedName ?? string.Empty, + TaskCenter = new ModuleTaskCenterCapabilities + { + CanPause = true, + CanResume = true, + CanEdit = true, + CanRerun = true, + EditComponentType = typeof(MessageReportTaskEditor).AssemblyQualifiedName ?? string.Empty, + AutoPauseBeforeEdit = true + }, + Order = 150 + }; + } +} diff --git a/src/TelegramPanel.Modules.MessageReportTask/Services/MessageReportTaskHandler.cs b/src/TelegramPanel.Modules.MessageReportTask/Services/MessageReportTaskHandler.cs new file mode 100644 index 0000000..3b88951 --- /dev/null +++ b/src/TelegramPanel.Modules.MessageReportTask/Services/MessageReportTaskHandler.cs @@ -0,0 +1,429 @@ +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using TelegramPanel.Core.Services; +using TelegramPanel.Data.Entities; +using TelegramPanel.Modules; + +namespace TelegramPanel.Modules.MessageReportTask.Services; + +public sealed class MessageReportTaskHandler : IModuleTaskHandler +{ + private const int MaxFailureLines = 50; + + public string TaskType => MessageReportTaskConstants.TaskType; + + public async Task ExecuteAsync(IModuleTaskExecutionHost host, CancellationToken cancellationToken) + { + var logger = host.Services.GetRequiredService>(); + var taskManagement = host.Services.GetRequiredService(); + var accountManagement = host.Services.GetRequiredService(); + var reportService = host.Services.GetRequiredService(); + + var config = DeserializeConfig(host.Config); + ValidateAndNormalizeConfig(config); + + var selectedCategoryIds = NormalizeSelectedCategoryIds(config).ToHashSet(); + var allAccounts = (await accountManagement.GetAllAccountsAsync()) + .Where(x => x.IsActive && x.UserId > 0 && x.Category?.ExcludeFromOperations != true) + .Where(x => x.CategoryId.HasValue && selectedCategoryIds.Contains(x.CategoryId.Value)) + .OrderBy(x => x.Id) + .ToList(); + + if (allAccounts.Count() == 0) + throw new InvalidOperationException("所选分类下没有可用执行账号"); + + var accountSlots = new List(); + foreach (var account in allAccounts) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!await host.IsStillRunningAsync(cancellationToken)) + { + config.Canceled = true; + await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(config)); + return; + } + + var slot = new AccountSlot(account); + foreach (var rawLink in config.MessageLinks) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!await host.IsStillRunningAsync(cancellationToken)) + { + config.Canceled = true; + await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(config)); + return; + } + + var resolved = await reportService.ResolveMessageTargetAsync(account.Id, rawLink, cancellationToken); + if (resolved.Success && resolved.Target != null) + { + slot.Targets.Add(new TargetSlot(resolved.Target)); + continue; + } + + AddFailure(config, account, rawLink, NormalizeReason(resolved.Error)); + } + + if (slot.Targets.Count > 0) + accountSlots.Add(slot); + } + + if (accountSlots.Count == 0) + { + config.Error = "没有可用的账号-消息组合(请确认这些账号能访问目标消息)"; + await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(config)); + throw new InvalidOperationException(config.Error); + } + + config.Error = null; + await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(config)); + + var completed = 0; + var failed = 0; + var accountQueueIndex = 0; + var targetQueueIndexByAccountId = new Dictionary(); + var lastProgressPersistAt = DateTime.UtcNow; + + try + { + while (!cancellationToken.IsCancellationRequested && accountSlots.Count > 0) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (!await host.IsStillRunningAsync(cancellationToken)) + { + config.Canceled = true; + break; + } + + if (config.MaxReports > 0 && completed >= config.MaxReports) + break; + + var accountIdx = SelectNextAccountIndex(accountSlots, ref accountQueueIndex); + if (accountIdx < 0) + break; + + var accountSlot = accountSlots[accountIdx]; + var targetIdx = SelectNextTargetIndex(accountSlot, targetQueueIndexByAccountId); + if (targetIdx < 0) + { + accountSlots.RemoveAt(accountIdx); + targetQueueIndexByAccountId.Remove(accountSlot.Account.Id); + continue; + } + + var targetSlot = accountSlot.Targets[targetIdx]; + var result = await reportService.ReportMessageAsync(accountSlot.Account.Id, targetSlot.Target, config, cancellationToken); + + completed++; + var hadFailureThisRound = false; + var shouldRemoveTarget = result.Success || result.ShouldRemoveTarget; + + if (!result.Success) + { + failed++; + hadFailureThisRound = true; + targetSlot.ConsecutiveFailures++; + AddFailure(config, accountSlot.Account, targetSlot.Target.RawInput, NormalizeReason(result.Error)); + if (targetSlot.ConsecutiveFailures >= 3) + shouldRemoveTarget = true; + + await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(config)); + } + + if (shouldRemoveTarget) + { + accountSlot.Targets.RemoveAt(targetIdx); + if (accountSlot.Targets.Count == 0) + { + accountSlots.RemoveAt(accountIdx); + targetQueueIndexByAccountId.Remove(accountSlot.Account.Id); + } + } + + if (ShouldPersistProgress(completed, hadFailureThisRound, lastProgressPersistAt)) + { + await host.UpdateProgressAsync(completed, failed, cancellationToken); + lastProgressPersistAt = DateTime.UtcNow; + } + + if (config.MaxReports > 0 && completed >= config.MaxReports) + break; + + if (accountSlots.Count == 0) + break; + + var delayMs = NextDelayMilliseconds(config.DelayMinMs, config.DelayMaxMs); + if (delayMs > 0) + { + var keepRunning = await DelayWithTaskCheckAsync(host, delayMs, cancellationToken); + if (!keepRunning) + { + config.Canceled = true; + break; + } + } + } + } + catch (Exception ex) + { + logger.LogWarning(ex, "MessageReport task failed (taskId={TaskId})", host.TaskId); + config.Error = ex.Message; + await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(config)); + throw; + } + + if (config.MaxReports > 0 && accountSlots.Count == 0 && completed < host.Total) + await taskManagement.UpdateTaskDraftAsync(host.TaskId, completed, SerializeIndented(config)); + else + await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(config)); + + await host.UpdateProgressAsync(completed, failed, cancellationToken); + if (config.Canceled) + { + await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(config)); + return; + } + + config.Error = null; + await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(config)); + } + + private static MessageReportTaskConfig DeserializeConfig(string? rawConfig) + { + var raw = (rawConfig ?? string.Empty).Trim(); + if (raw.Length == 0) + throw new InvalidOperationException("任务配置为空"); + + try + { + return JsonSerializer.Deserialize(raw) + ?? throw new InvalidOperationException("任务配置解析结果为空"); + } + catch (Exception ex) + { + throw new InvalidOperationException($"任务配置 JSON 无效:{ex.Message}"); + } + } + + private static void ValidateAndNormalizeConfig(MessageReportTaskConfig config) + { + config.CategoryIds = NormalizeSelectedCategoryIds(config); + if (config.CategoryIds.Count == 0) + throw new InvalidOperationException("请至少选择一个执行账号分类"); + + config.CategoryId = config.CategoryIds[0]; + config.CategoryNames = NormalizeCategoryNames(config.CategoryNames, config.CategoryName); + config.CategoryName = config.CategoryNames.FirstOrDefault() ?? config.CategoryName; + + config.MessageLinks = (config.MessageLinks ?? new List()) + .Select(x => (x ?? string.Empty).Trim()) + .Where(x => x.Length > 0) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + if (config.MessageLinks.Count == 0) + throw new InvalidOperationException("请至少填写一条消息链接"); + + config.OptionKeywords = (config.OptionKeywords ?? new List()) + .Select(x => (x ?? string.Empty).Trim()) + .Where(x => x.Length > 0) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + config.ReportPreset = NormalizePreset(config.ReportPreset); + if (config.ReportPreset == MessageReportTaskPresets.Custom && config.OptionKeywords.Count == 0) + throw new InvalidOperationException("选择“自定义关键词”时,至少填写一个关键词"); + + config.Comment = NormalizeNullableText(config.Comment); + if (config.DelayMinMs < 0) config.DelayMinMs = 0; + if (config.DelayMaxMs < 0) config.DelayMaxMs = 0; + if (config.DelayMinMs > 86400000) config.DelayMinMs = 86400000; + if (config.DelayMaxMs > 86400000) config.DelayMaxMs = 86400000; + if (config.DelayMaxMs < config.DelayMinMs) config.DelayMaxMs = config.DelayMinMs; + if (config.MaxReports < 0) config.MaxReports = 0; + + config.Canceled = false; + config.Error = NormalizeNullableText(config.Error); + config.RecentFailures ??= new List(); + } + + private static List NormalizeSelectedCategoryIds(MessageReportTaskConfig config) + { + var ids = (config.CategoryIds ?? new List()) + .Where(x => x > 0) + .Distinct() + .ToList(); + + if (ids.Count == 0 && config.CategoryId > 0) + ids.Add(config.CategoryId); + + return ids; + } + + private static List NormalizeCategoryNames(IEnumerable? values, string? fallback) + { + var names = (values ?? Array.Empty()) + .Select(x => (x ?? string.Empty).Trim()) + .Where(x => x.Length > 0) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + var fallbackName = (fallback ?? string.Empty).Trim(); + if (names.Count == 0 && fallbackName.Length > 0) + names.Add(fallbackName); + + return names; + } + + private static string NormalizePreset(string? preset) + { + var value = (preset ?? string.Empty).Trim().ToLowerInvariant(); + return value switch + { + MessageReportTaskPresets.Spam => MessageReportTaskPresets.Spam, + MessageReportTaskPresets.Violence => MessageReportTaskPresets.Violence, + MessageReportTaskPresets.Pornography => MessageReportTaskPresets.Pornography, + MessageReportTaskPresets.ChildAbuse => MessageReportTaskPresets.ChildAbuse, + MessageReportTaskPresets.Copyright => MessageReportTaskPresets.Copyright, + MessageReportTaskPresets.IllegalDrugs => MessageReportTaskPresets.IllegalDrugs, + MessageReportTaskPresets.PersonalDetails => MessageReportTaskPresets.PersonalDetails, + MessageReportTaskPresets.Other => MessageReportTaskPresets.Other, + MessageReportTaskPresets.FirstAvailable => MessageReportTaskPresets.FirstAvailable, + MessageReportTaskPresets.Custom => MessageReportTaskPresets.Custom, + _ => MessageReportTaskPresets.Spam + }; + } + + private static string? NormalizeNullableText(string? value) + { + var text = (value ?? string.Empty).Trim(); + return text.Length == 0 ? null : text; + } + + private static int SelectNextAccountIndex(List slots, ref int queueIndex) + { + if (slots.Count == 0) + return -1; + + if (queueIndex < 0 || queueIndex >= slots.Count) + queueIndex = 0; + + var selected = queueIndex; + queueIndex = (queueIndex + 1) % slots.Count; + return selected; + } + + private static int SelectNextTargetIndex(AccountSlot slot, Dictionary targetQueueIndexByAccountId) + { + if (slot.Targets.Count == 0) + return -1; + + if (!targetQueueIndexByAccountId.TryGetValue(slot.Account.Id, out var index)) + index = 0; + + if (index < 0 || index >= slot.Targets.Count) + index = 0; + + var selected = index; + targetQueueIndexByAccountId[slot.Account.Id] = (selected + 1) % slot.Targets.Count; + return selected; + } + + private static int NextDelayMilliseconds(int min, int max) + { + if (min <= 0 && max <= 0) + return 0; + if (max <= min) + return Math.Max(0, min); + return Random.Shared.Next(min, max + 1); + } + + private static bool ShouldPersistProgress(int completed, bool hadFailureThisRound, DateTime lastPersistAtUtc) + { + if (completed <= 0) + return hadFailureThisRound; + + if (hadFailureThisRound) + return true; + + if (completed % 5 == 0) + return true; + + return (DateTime.UtcNow - lastPersistAtUtc) >= TimeSpan.FromSeconds(10); + } + + private static async Task DelayWithTaskCheckAsync(IModuleTaskExecutionHost host, int delayMs, CancellationToken cancellationToken) + { + if (delayMs <= 0) + return true; + + var remaining = delayMs; + while (remaining > 0) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!await host.IsStillRunningAsync(cancellationToken)) + return false; + + var chunk = Math.Min(remaining, 1000); + await Task.Delay(chunk, cancellationToken); + remaining -= chunk; + } + + return true; + } + + private static void AddFailure(MessageReportTaskConfig config, Account account, string messageLink, string reason) + { + config.RecentFailures ??= new List(); + config.RecentFailures.Insert(0, new MessageReportTaskRuntimeFailure + { + TimeUtc = DateTime.UtcNow, + AccountId = account.Id, + Account = BuildAccountDisplayName(account), + MessageLink = (messageLink ?? string.Empty).Trim(), + Reason = reason + }); + + if (config.RecentFailures.Count > MaxFailureLines) + config.RecentFailures = config.RecentFailures.Take(MaxFailureLines).ToList(); + } + + private static string BuildAccountDisplayName(Account account) + { + var username = string.IsNullOrWhiteSpace(account.Username) ? string.Empty : $"@{account.Username}"; + var nickname = string.IsNullOrWhiteSpace(account.Nickname) ? string.Empty : account.Nickname.Trim(); + return string.Join(" / ", new[] { account.DisplayPhone, username, nickname }.Where(x => !string.IsNullOrWhiteSpace(x))); + } + + private static string NormalizeReason(string? reason) + { + var text = (reason ?? string.Empty).Trim(); + return text.Length == 0 ? "未知错误" : text; + } + + private static string SerializeIndented(MessageReportTaskConfig config) + => JsonSerializer.Serialize(config, new JsonSerializerOptions { WriteIndented = true }); + + private sealed class AccountSlot + { + public AccountSlot(Account account) + { + Account = account; + } + + public Account Account { get; } + public List Targets { get; } = new(); + } + + private sealed class TargetSlot + { + public TargetSlot(TelegramMessageReportService.ResolvedReportMessageTarget target) + { + Target = target; + } + + public TelegramMessageReportService.ResolvedReportMessageTarget Target { get; } + public int ConsecutiveFailures { get; set; } + } +} diff --git a/src/TelegramPanel.Modules.MessageReportTask/Services/MessageReportTaskModels.cs b/src/TelegramPanel.Modules.MessageReportTask/Services/MessageReportTaskModels.cs new file mode 100644 index 0000000..2b0242d --- /dev/null +++ b/src/TelegramPanel.Modules.MessageReportTask/Services/MessageReportTaskModels.cs @@ -0,0 +1,86 @@ +using System.Text.Json.Serialization; + +namespace TelegramPanel.Modules.MessageReportTask.Services; + +public static class MessageReportTaskConstants +{ + public const string ModuleId = "task.message-report"; + public const string TaskType = "user_message_report"; +} + +public static class MessageReportTaskPresets +{ + public const string Spam = "spam"; + public const string Violence = "violence"; + public const string Pornography = "pornography"; + public const string ChildAbuse = "child_abuse"; + public const string Copyright = "copyright"; + public const string IllegalDrugs = "illegal_drugs"; + public const string PersonalDetails = "personal_details"; + public const string Other = "other"; + public const string FirstAvailable = "first_available"; + public const string Custom = "custom"; +} + +public sealed class MessageReportTaskConfig +{ + [JsonPropertyName("category_id")] + public int CategoryId { get; set; } + + [JsonPropertyName("category_name")] + public string? CategoryName { get; set; } + + [JsonPropertyName("category_ids")] + public List CategoryIds { get; set; } = new(); + + [JsonPropertyName("category_names")] + public List CategoryNames { get; set; } = new(); + + [JsonPropertyName("message_links")] + public List MessageLinks { get; set; } = new(); + + [JsonPropertyName("delay_min_ms")] + public int DelayMinMs { get; set; } = 15000; + + [JsonPropertyName("delay_max_ms")] + public int DelayMaxMs { get; set; } = 45000; + + [JsonPropertyName("max_reports")] + public int MaxReports { get; set; } + + [JsonPropertyName("report_preset")] + public string ReportPreset { get; set; } = MessageReportTaskPresets.Spam; + + [JsonPropertyName("option_keywords")] + public List OptionKeywords { get; set; } = new(); + + [JsonPropertyName("comment")] + public string? Comment { get; set; } + + [JsonPropertyName("canceled")] + public bool Canceled { get; set; } + + [JsonPropertyName("error")] + public string? Error { get; set; } + + [JsonPropertyName("recent_failures")] + public List RecentFailures { get; set; } = new(); +} + +public sealed class MessageReportTaskRuntimeFailure +{ + [JsonPropertyName("time_utc")] + public DateTime TimeUtc { get; set; } + + [JsonPropertyName("account_id")] + public int AccountId { get; set; } + + [JsonPropertyName("account")] + public string Account { get; set; } = string.Empty; + + [JsonPropertyName("message_link")] + public string MessageLink { get; set; } = string.Empty; + + [JsonPropertyName("reason")] + public string Reason { get; set; } = string.Empty; +} diff --git a/src/TelegramPanel.Modules.MessageReportTask/Services/MessageReportTaskRerunBuilder.cs b/src/TelegramPanel.Modules.MessageReportTask/Services/MessageReportTaskRerunBuilder.cs new file mode 100644 index 0000000..8c17012 --- /dev/null +++ b/src/TelegramPanel.Modules.MessageReportTask/Services/MessageReportTaskRerunBuilder.cs @@ -0,0 +1,132 @@ +using System.Text.Json; +using TelegramPanel.Modules; + +namespace TelegramPanel.Modules.MessageReportTask.Services; + +public sealed class MessageReportTaskRerunBuilder : IModuleTaskRerunBuilder +{ + public string TaskType => MessageReportTaskConstants.TaskType; + + public ModuleTaskCreateRequest Build(ModuleTaskSnapshot task) + { + var rerunConfig = BuildRerunConfig(task.Config); + var total = rerunConfig.MaxReports > 0 ? rerunConfig.MaxReports : 0; + var configJson = JsonSerializer.Serialize(rerunConfig, new JsonSerializerOptions { WriteIndented = true }); + + return new ModuleTaskCreateRequest + { + TaskType = MessageReportTaskConstants.TaskType, + Total = total, + Config = configJson + }; + } + + private static MessageReportTaskConfig BuildRerunConfig(string? rawConfig) + { + var raw = (rawConfig ?? string.Empty).Trim(); + if (raw.Length == 0) + throw new InvalidOperationException("任务配置为空,无法重新运行"); + + MessageReportTaskConfig cfg; + try + { + cfg = JsonSerializer.Deserialize(raw) + ?? throw new InvalidOperationException("任务配置解析结果为空"); + } + catch (Exception ex) + { + throw new InvalidOperationException($"任务配置 JSON 无效:{ex.Message}"); + } + + cfg.CategoryIds = NormalizeCategoryIds(cfg.CategoryIds, cfg.CategoryId); + if (cfg.CategoryIds.Count == 0) + throw new InvalidOperationException("任务缺少账号分类,无法重新运行"); + + cfg.CategoryId = cfg.CategoryIds[0]; + cfg.CategoryNames = NormalizeCategoryNames(cfg.CategoryNames, cfg.CategoryName); + cfg.CategoryName = cfg.CategoryNames.FirstOrDefault() ?? cfg.CategoryName; + + cfg.MessageLinks = (cfg.MessageLinks ?? new List()) + .Select(x => (x ?? string.Empty).Trim()) + .Where(x => x.Length > 0) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (cfg.MessageLinks.Count == 0) + throw new InvalidOperationException("任务缺少消息链接,无法重新运行"); + + cfg.OptionKeywords = (cfg.OptionKeywords ?? new List()) + .Select(x => (x ?? string.Empty).Trim()) + .Where(x => x.Length > 0) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (cfg.DelayMinMs < 0) cfg.DelayMinMs = 0; + if (cfg.DelayMaxMs < 0) cfg.DelayMaxMs = 0; + if (cfg.DelayMinMs > 86400000) cfg.DelayMinMs = 86400000; + if (cfg.DelayMaxMs > 86400000) cfg.DelayMaxMs = 86400000; + if (cfg.DelayMaxMs < cfg.DelayMinMs) cfg.DelayMaxMs = cfg.DelayMinMs; + if (cfg.MaxReports < 0) cfg.MaxReports = 0; + + cfg.ReportPreset = NormalizePreset(cfg.ReportPreset); + cfg.Comment = NormalizeNullableText(cfg.Comment); + cfg.Canceled = false; + cfg.Error = null; + cfg.RecentFailures = new List(); + + return cfg; + } + + private static List NormalizeCategoryIds(IEnumerable? values, int fallback) + { + var ids = (values ?? Array.Empty()) + .Where(x => x > 0) + .Distinct() + .ToList(); + + if (ids.Count == 0 && fallback > 0) + ids.Add(fallback); + + return ids; + } + + private static List NormalizeCategoryNames(IEnumerable? values, string? fallback) + { + var names = (values ?? Array.Empty()) + .Select(x => (x ?? string.Empty).Trim()) + .Where(x => x.Length > 0) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + var fallbackName = (fallback ?? string.Empty).Trim(); + if (names.Count == 0 && fallbackName.Length > 0) + names.Add(fallbackName); + + return names; + } + + private static string NormalizePreset(string? preset) + { + var value = (preset ?? string.Empty).Trim().ToLowerInvariant(); + return value switch + { + MessageReportTaskPresets.Spam => MessageReportTaskPresets.Spam, + MessageReportTaskPresets.Violence => MessageReportTaskPresets.Violence, + MessageReportTaskPresets.Pornography => MessageReportTaskPresets.Pornography, + MessageReportTaskPresets.ChildAbuse => MessageReportTaskPresets.ChildAbuse, + MessageReportTaskPresets.Copyright => MessageReportTaskPresets.Copyright, + MessageReportTaskPresets.IllegalDrugs => MessageReportTaskPresets.IllegalDrugs, + MessageReportTaskPresets.PersonalDetails => MessageReportTaskPresets.PersonalDetails, + MessageReportTaskPresets.Other => MessageReportTaskPresets.Other, + MessageReportTaskPresets.FirstAvailable => MessageReportTaskPresets.FirstAvailable, + MessageReportTaskPresets.Custom => MessageReportTaskPresets.Custom, + _ => MessageReportTaskPresets.Spam + }; + } + + private static string? NormalizeNullableText(string? value) + { + var text = (value ?? string.Empty).Trim(); + return text.Length == 0 ? null : text; + } +} diff --git a/src/TelegramPanel.Modules.MessageReportTask/Services/TelegramMessageReportService.cs b/src/TelegramPanel.Modules.MessageReportTask/Services/TelegramMessageReportService.cs new file mode 100644 index 0000000..3a09881 --- /dev/null +++ b/src/TelegramPanel.Modules.MessageReportTask/Services/TelegramMessageReportService.cs @@ -0,0 +1,483 @@ +using System.Globalization; +using System.Text; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using TelegramPanel.Core.Interfaces; +using TelegramPanel.Core.Services.Telegram; +using TL; +using WTelegram; + +namespace TelegramPanel.Modules.MessageReportTask.Services; + +public sealed class TelegramMessageReportService +{ + private readonly AccountTelegramToolsService _accountTools; + private readonly ITelegramClientPool _clientPool; + private readonly IConfiguration _configuration; + private readonly ILogger _logger; + + public TelegramMessageReportService( + AccountTelegramToolsService accountTools, + ITelegramClientPool clientPool, + IConfiguration configuration, + ILogger logger) + { + _accountTools = accountTools; + _clientPool = clientPool; + _configuration = configuration; + _logger = logger; + } + + public sealed record ResolvedReportMessageTarget( + AccountTelegramToolsService.ResolvedChatTarget Chat, + int MessageId, + string CanonicalLink, + string RawInput); + + public sealed record ReportMessageResult( + bool Success, + string? Error, + string? SelectedOptionText, + bool ShouldRemoveTarget); + + private sealed record ParsedMessageLink(string ResolveTarget, int MessageId, string CanonicalLink); + + public async Task<(bool Success, string? Error, ResolvedReportMessageTarget? Target)> ResolveMessageTargetAsync( + int accountId, + string rawInput, + CancellationToken cancellationToken = default) + { + try + { + var parsed = ParseMessageLink(rawInput); + var resolved = await _accountTools.ResolveChatTargetAsync(accountId, parsed.ResolveTarget, cancellationToken); + if (!resolved.Success || resolved.Target == null) + return (false, resolved.Error ?? "无法解析消息链接", null); + + return (true, null, new ResolvedReportMessageTarget(resolved.Target, parsed.MessageId, parsed.CanonicalLink, (rawInput ?? string.Empty).Trim())); + } + catch (Exception ex) + { + var message = ex is ArgumentException or FormatException or InvalidOperationException + ? ex.Message + : BuildTelegramError(ex); + return (false, message, null); + } + } + + public async Task ReportMessageAsync( + int accountId, + ResolvedReportMessageTarget target, + MessageReportTaskConfig config, + CancellationToken cancellationToken = default) + { + try + { + var client = await EnsureConnectedClientAsync(accountId, target.Chat.CanonicalId, cancellationToken); + + byte[] option = Array.Empty(); + var comment = string.Empty; + string? selectedOptionText = null; + + var result = await ExecuteTelegramRequestAsync( + accountId, + $"举报消息({target.CanonicalLink})", + () => client.Messages_Report(target.Chat.Peer, new[] { target.MessageId }, option, comment), + cancellationToken, + resetClientOnTimeout: true); + + for (var depth = 0; depth < 8; depth++) + { + cancellationToken.ThrowIfCancellationRequested(); + + switch (result) + { + case ReportResultReported: + return new ReportMessageResult(true, null, selectedOptionText, true); + + case ReportResultChooseOption choose: + { + var selected = SelectOption(choose.options, config); + if (selected == null) + { + var available = BuildOptionListText(choose.options); + var error = available.Length == 0 + ? "Telegram 返回了空的举报选项列表" + : $"未匹配到举报类型,对应可选项:{available}"; + return new ReportMessageResult(false, error, selectedOptionText, true); + } + + option = selected.option ?? Array.Empty(); + selectedOptionText = NormalizeNullableText(selected.text); + + result = await ExecuteTelegramRequestAsync( + accountId, + $"选择举报类型({selectedOptionText ?? "未命名选项"})", + () => client.Messages_Report(target.Chat.Peer, new[] { target.MessageId }, option, comment), + cancellationToken, + resetClientOnTimeout: true); + break; + } + + case ReportResultAddComment addComment: + { + option = addComment.option ?? option; + comment = BuildComment(config.Comment, target); + var optional = addComment.flags.HasFlag(ReportResultAddComment.Flags.optional); + if (string.IsNullOrWhiteSpace(comment) && !optional) + return new ReportMessageResult(false, "当前举报类型要求填写举报文案,请先补充文案后再执行", selectedOptionText, true); + + result = await ExecuteTelegramRequestAsync( + accountId, + $"提交举报文案({target.CanonicalLink})", + () => client.Messages_Report(target.Chat.Peer, new[] { target.MessageId }, option, comment), + cancellationToken, + resetClientOnTimeout: true); + break; + } + + default: + return new ReportMessageResult(false, $"不支持的举报返回类型:{result.GetType().Name}", selectedOptionText, true); + } + } + + return new ReportMessageResult(false, "举报流程超过安全重试次数,请稍后再试", selectedOptionText, false); + } + catch (Exception ex) + { + var error = BuildTelegramError(ex); + return new ReportMessageResult(false, error, null, ShouldRemoveTargetAfterFailure(error)); + } + } + + private async Task EnsureConnectedClientAsync(int accountId, string resolveHint, CancellationToken cancellationToken) + { + var existing = _clientPool.GetClient(accountId); + if (existing?.User != null) + return existing; + + var resolved = await _accountTools.ResolveChatTargetAsync(accountId, resolveHint, cancellationToken); + if (!resolved.Success) + throw new InvalidOperationException(resolved.Error ?? "无法建立 Telegram 客户端连接"); + + var client = _clientPool.GetClient(accountId); + if (client?.User != null) + return client; + + throw new InvalidOperationException("无法建立 Telegram 客户端连接"); + } + + private static ParsedMessageLink ParseMessageLink(string rawInput) + { + var raw = (rawInput ?? string.Empty).Trim(); + if (raw.Length == 0) + throw new ArgumentException("消息链接为空", nameof(rawInput)); + + if (raw.StartsWith("tg://", StringComparison.OrdinalIgnoreCase) + && Uri.TryCreate(raw, UriKind.Absolute, out var tgUri) + && string.Equals(tgUri.Host, "privatepost", StringComparison.OrdinalIgnoreCase)) + { + var query = ParseQueryString(tgUri.Query); + if (!query.TryGetValue("channel", out var channelText) || string.IsNullOrWhiteSpace(channelText)) + throw new ArgumentException("tg://privatepost 缺少 channel 参数", nameof(rawInput)); + if (!query.TryGetValue("post", out var postText) || string.IsNullOrWhiteSpace(postText)) + throw new ArgumentException("tg://privatepost 缺少 post 参数", nameof(rawInput)); + + var internalId = NormalizeInternalChatId(channelText); + var messageId = ParsePositiveMessageId(postText); + return new ParsedMessageLink($"-100{internalId}", messageId, $"https://t.me/c/{internalId}/{messageId}"); + } + + var normalizedUrl = raw; + if (normalizedUrl.StartsWith("t.me/", StringComparison.OrdinalIgnoreCase) + || normalizedUrl.StartsWith("telegram.me/", StringComparison.OrdinalIgnoreCase)) + { + normalizedUrl = "https://" + normalizedUrl; + } + + if (Uri.TryCreate(normalizedUrl, UriKind.Absolute, out var uri) + && (string.Equals(uri.Host, "t.me", StringComparison.OrdinalIgnoreCase) + || string.Equals(uri.Host, "telegram.me", StringComparison.OrdinalIgnoreCase))) + { + var segments = (uri.AbsolutePath ?? string.Empty) + .Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + return ParseSegments(segments, rawInput); + } + + var plain = raw; + var queryIndex = plain.IndexOfAny(new[] { '?', '#' }); + if (queryIndex >= 0) + plain = plain[..queryIndex]; + + var pathSegments = plain + .Trim('/') + .Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + return ParseSegments(pathSegments, rawInput); + } + + private static ParsedMessageLink ParseSegments(string[] segments, string? rawInput) + { + if (segments.Length >= 3 && string.Equals(segments[0], "c", StringComparison.OrdinalIgnoreCase)) + { + var internalId = NormalizeInternalChatId(segments[1]); + var messageId = ParsePositiveMessageId(segments[2]); + return new ParsedMessageLink($"-100{internalId}", messageId, $"https://t.me/c/{internalId}/{messageId}"); + } + + if (segments.Length >= 3 && (string.Equals(segments[0], "b", StringComparison.OrdinalIgnoreCase) + || string.Equals(segments[0], "s", StringComparison.OrdinalIgnoreCase))) + { + var target = NormalizeTargetToken(segments[1]); + var messageId = ParsePositiveMessageId(segments[2]); + return new ParsedMessageLink(target, messageId, $"https://t.me/{target.TrimStart('@')}/{messageId}"); + } + + if (segments.Length >= 2) + { + var target = NormalizeTargetToken(segments[0]); + var messageId = ParsePositiveMessageId(segments[1]); + if (long.TryParse(target, NumberStyles.Integer, CultureInfo.InvariantCulture, out _)) + return new ParsedMessageLink(target, messageId, $"{target}/{messageId}"); + + return new ParsedMessageLink(target, messageId, $"https://t.me/{target.TrimStart('@')}/{messageId}"); + } + + throw new ArgumentException($"不支持的消息链接格式:{(rawInput ?? string.Empty).Trim()}", nameof(rawInput)); + } + + private static string NormalizeInternalChatId(string value) + { + var text = (value ?? string.Empty).Trim(); + if (text.StartsWith("-100", StringComparison.Ordinal)) + text = text[4..]; + + if (!long.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) || parsed <= 0) + throw new FormatException($"私有消息链接中的频道 ID 无效:{value}"); + + return parsed.ToString(CultureInfo.InvariantCulture); + } + + private static string NormalizeTargetToken(string value) + { + var text = (value ?? string.Empty).Trim(); + if (text.Length == 0) + throw new FormatException("消息链接中缺少目标标识"); + + if (long.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out _)) + return text; + + return text.TrimStart('@'); + } + + private static int ParsePositiveMessageId(string value) + { + var text = (value ?? string.Empty).Trim(); + if (!int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) || parsed <= 0) + throw new FormatException($"消息 ID 无效:{value}"); + return parsed; + } + + private static Dictionary ParseQueryString(string query) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + var text = (query ?? string.Empty).Trim(); + if (text.StartsWith("?", StringComparison.Ordinal)) + text = text[1..]; + if (text.Length == 0) + return result; + + foreach (var pair in text.Split('&', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + var parts = pair.Split('=', 2); + var key = Uri.UnescapeDataString((parts[0] ?? string.Empty).Trim()); + if (key.Length == 0) + continue; + + var value = parts.Length > 1 ? Uri.UnescapeDataString((parts[1] ?? string.Empty).Trim()) : string.Empty; + result[key] = value; + } + + return result; + } + + private static MessageReportOption? SelectOption(IEnumerable? options, MessageReportTaskConfig config) + { + var list = (options ?? Array.Empty()) + .Where(x => x != null) + .ToList(); + if (list.Count == 0) + return null; + + if (string.Equals(config.ReportPreset, MessageReportTaskPresets.FirstAvailable, StringComparison.OrdinalIgnoreCase)) + return list[0]; + + var normalizedKeywords = BuildNormalizedKeywords(config); + if (normalizedKeywords.Count == 0) + return null; + + foreach (var option in list) + { + var text = NormalizeKeyword(option.text); + if (text.Length == 0) + continue; + + if (normalizedKeywords.Any(keyword => text.Contains(keyword, StringComparison.Ordinal))) + return option; + } + + return null; + } + + private static List BuildNormalizedKeywords(MessageReportTaskConfig config) + { + var custom = (config.OptionKeywords ?? new List()) + .Select(NormalizeKeyword) + .Where(x => x.Length > 0) + .Distinct(StringComparer.Ordinal) + .ToList(); + if (custom.Count > 0) + return custom; + + return GetPresetKeywords(config.ReportPreset) + .Select(NormalizeKeyword) + .Where(x => x.Length > 0) + .Distinct(StringComparer.Ordinal) + .ToList(); + } + + private static IEnumerable GetPresetKeywords(string? preset) + { + return (preset ?? string.Empty).Trim().ToLowerInvariant() switch + { + MessageReportTaskPresets.Spam => new[] { "spam", "垃圾", "骚扰", "广告" }, + MessageReportTaskPresets.Violence => new[] { "violence", "violent", "暴力", "威胁" }, + MessageReportTaskPresets.Pornography => new[] { "porn", "pornography", "色情", "淫秽" }, + MessageReportTaskPresets.ChildAbuse => new[] { "child", "childabuse", "儿童", "未成年人" }, + MessageReportTaskPresets.Copyright => new[] { "copyright", "侵权", "版权" }, + MessageReportTaskPresets.IllegalDrugs => new[] { "drugs", "illegaldrugs", "毒品", "违禁药物" }, + MessageReportTaskPresets.PersonalDetails => new[] { "personal", "personaldetails", "隐私", "个人信息" }, + MessageReportTaskPresets.Other => new[] { "other", "其他" }, + _ => Array.Empty() + }; + } + + private static string BuildComment(string? template, ResolvedReportMessageTarget target) + { + var text = (template ?? string.Empty).Trim(); + if (text.Length == 0) + return string.Empty; + + return text + .Replace("{消息链接}", target.CanonicalLink, StringComparison.OrdinalIgnoreCase) + .Replace("{message_link}", target.CanonicalLink, StringComparison.OrdinalIgnoreCase) + .Replace("{聊天标题}", target.Chat.Title, StringComparison.OrdinalIgnoreCase) + .Replace("{chat_title}", target.Chat.Title, StringComparison.OrdinalIgnoreCase) + .Replace("{消息ID}", target.MessageId.ToString(CultureInfo.InvariantCulture), StringComparison.OrdinalIgnoreCase) + .Replace("{message_id}", target.MessageId.ToString(CultureInfo.InvariantCulture), StringComparison.OrdinalIgnoreCase); + } + + private static string BuildOptionListText(IEnumerable? options) + { + return string.Join(" / ", (options ?? Array.Empty()) + .Select(x => NormalizeNullableText(x?.text)) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Distinct(StringComparer.OrdinalIgnoreCase)); + } + + private static string NormalizeKeyword(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return string.Empty; + + var builder = new StringBuilder(value.Length); + foreach (var ch in value.Trim().ToLowerInvariant()) + { + if (char.IsLetterOrDigit(ch) || ch >= 0x4e00) + builder.Append(ch); + } + + return builder.ToString(); + } + + private static string? NormalizeNullableText(string? value) + { + var text = (value ?? string.Empty).Trim(); + return text.Length == 0 ? null : text; + } + + private string BuildTelegramError(Exception ex) + { + var (summary, details) = AccountTelegramToolsService.MapTelegramException(ex); + return string.IsNullOrWhiteSpace(details) ? summary : $"{summary}:{details}"; + } + + private static bool ShouldRemoveTargetAfterFailure(string error) + { + if (string.IsNullOrWhiteSpace(error)) + return false; + + if (LooksLikeTemporaryFailure(error)) + return false; + + return error.Contains("ALREADY_REPORTED", StringComparison.OrdinalIgnoreCase) + || error.Contains("MESSAGE_ALREADY_REPORTED", StringComparison.OrdinalIgnoreCase) + || error.Contains("MESSAGE_ID_INVALID", StringComparison.OrdinalIgnoreCase) + || error.Contains("MSG_ID_INVALID", StringComparison.OrdinalIgnoreCase) + || error.Contains("MESSAGE_NOT_FOUND", StringComparison.OrdinalIgnoreCase) + || error.Contains("CHAT_ID_INVALID", StringComparison.OrdinalIgnoreCase) + || error.Contains("PEER_ID_INVALID", StringComparison.OrdinalIgnoreCase) + || error.Contains("CHANNEL_INVALID", StringComparison.OrdinalIgnoreCase) + || error.Contains("CHANNEL_PRIVATE", StringComparison.OrdinalIgnoreCase) + || error.Contains("GROUPED_MEDIA_INVALID", StringComparison.OrdinalIgnoreCase) + || error.Contains("未匹配到举报类型", StringComparison.OrdinalIgnoreCase) + || error.Contains("要求填写举报文案", StringComparison.OrdinalIgnoreCase) + || error.Contains("不支持的举报返回类型", StringComparison.OrdinalIgnoreCase) + || error.Contains("链接格式", StringComparison.OrdinalIgnoreCase); + } + + private static bool LooksLikeTemporaryFailure(string error) + { + return error.Contains("FLOOD_WAIT", StringComparison.OrdinalIgnoreCase) + || error.Contains("请求超时", StringComparison.OrdinalIgnoreCase) + || error.Contains("timeout", StringComparison.OrdinalIgnoreCase) + || error.Contains("timed out", StringComparison.OrdinalIgnoreCase) + || error.Contains("网络", StringComparison.OrdinalIgnoreCase) + || error.Contains("NETWORK", StringComparison.OrdinalIgnoreCase); + } + + private TimeSpan GetTelegramRequestTimeout() + { + var seconds = int.TryParse(_configuration["Telegram:RequestTimeoutSeconds"], out var parsedSeconds) + ? parsedSeconds + : 90; + return TimeSpan.FromSeconds(Math.Clamp(seconds, 15, 600)); + } + + private async Task ExecuteTelegramRequestAsync( + int accountId, + string operation, + Func> action, + CancellationToken cancellationToken, + bool resetClientOnTimeout) + { + var timeout = GetTelegramRequestTimeout(); + + try + { + return await action().WaitAsync(timeout, cancellationToken); + } + catch (TimeoutException) + { + _logger.LogWarning( + "Telegram request timed out after {TimeoutSeconds}s for account {AccountId}: {Operation}", + timeout.TotalSeconds, + accountId, + operation); + + if (resetClientOnTimeout) + await _clientPool.RemoveClientAsync(accountId); + + throw new TimeoutException($"Telegram 请求超时:{operation} 超过 {timeout.TotalSeconds:0} 秒,可能是账号受限、网络异常或代理异常"); + } + } +} diff --git a/src/TelegramPanel.Modules.MessageReportTask/TelegramPanel.Modules.MessageReportTask.csproj b/src/TelegramPanel.Modules.MessageReportTask/TelegramPanel.Modules.MessageReportTask.csproj new file mode 100644 index 0000000..805a71e --- /dev/null +++ b/src/TelegramPanel.Modules.MessageReportTask/TelegramPanel.Modules.MessageReportTask.csproj @@ -0,0 +1,33 @@ + + + net8.0 + enable + enable + TelegramPanel.Modules.MessageReportTask + TelegramPanel.Modules.MessageReportTask + + + + + + + + + + + + + + + + + + Never + Never + + + Never + Never + + + diff --git a/src/TelegramPanel.Modules.MessageReportTask/_Imports.razor b/src/TelegramPanel.Modules.MessageReportTask/_Imports.razor new file mode 100644 index 0000000..1ae409c --- /dev/null +++ b/src/TelegramPanel.Modules.MessageReportTask/_Imports.razor @@ -0,0 +1,5 @@ +@using Microsoft.AspNetCore.Components +@using MudBlazor +@using TelegramPanel.Core.Services +@using TelegramPanel.Data.Entities +@using TelegramPanel.Modules diff --git a/src/TelegramPanel.Modules.MessageReportTask/manifest.json b/src/TelegramPanel.Modules.MessageReportTask/manifest.json new file mode 100644 index 0000000..7b1c89b --- /dev/null +++ b/src/TelegramPanel.Modules.MessageReportTask/manifest.json @@ -0,0 +1,13 @@ +{ + "id": "task.message-report", + "name": "任务:消息举报", + "version": "1.0.0", + "host": { + "min": "1.31.6", + "max": "2.0.0" + }, + "entry": { + "assembly": "TelegramPanel.Modules.MessageReportTask.dll", + "type": "TelegramPanel.Modules.MessageReportTask.MessageReportTaskModule" + } +} diff --git a/src/TelegramPanel.Web/Components/Dialogs/BatchChangeAvatarDialog.razor b/src/TelegramPanel.Web/Components/Dialogs/BatchChangeAvatarDialog.razor new file mode 100644 index 0000000..ae05e07 --- /dev/null +++ b/src/TelegramPanel.Web/Components/Dialogs/BatchChangeAvatarDialog.razor @@ -0,0 +1,316 @@ +@namespace TelegramPanel.Web.Components.Dialogs +@inject AccountManagementService AccountManagement +@inject TelegramPanel.Core.Services.Telegram.AccountTelegramToolsService AccountTelegramTools +@inject TelegramPanel.Core.Services.AccountRiskService RiskService +@inject ImageAssetStorageService AssetStorage +@inject TemplateRenderingService TemplateRendering +@inject DataDictionaryService DictionaryService +@inject ISnackbar Snackbar +@inject IDialogService DialogService +@using Microsoft.AspNetCore.Components.Forms +@using TelegramPanel.Data.Entities + + + + + + 将对选中的账号批量修改头像。支持固定上传单个头像,或选择图片字典变量按字典读取方式为每个账号取图。 + + + 账号数量:@accounts.Count + + + 固定上传 + 图片字典变量 + + + @if (IsFixedSource) + { + + + + 选择头像图片 + + + + + @if (fixedAvatarBytes is { Length: > 0 }) + { + 已选择:@fixedAvatarFileName(@fixedAvatarBytes.Length bytes) + } + } + else + { + + -- 请选择图片字典 -- + @foreach (var item in imageDictionaryOptions) + { + @item.DisplayText + } + + + @if (imageDictionaryOptions.Count > 0) + { + 可用图片变量:@string.Join("、", imageDictionaryOptions.Select(x => $"{{{x.Name}}}")) + } + } + + @if (running || results.Count > 0) + { + + + + 进度:@processed / @accounts.Count + 成功:@success + 失败:@failed + @if (running) + { + + } + + + + + 账号 + 头像来源 + 结果 + 原因 + + + @context.Phone + @context.Source + + + @(context.Success ? "成功" : "失败") + + + @((context.Error ?? "").Trim()) + + + } + + + + 关闭 + + @if (running) + { + + 执行中... + } + else + { + 开始修改 + } + + + + +@code +{ + private const string AvatarSourceFixed = "fixed"; + private const string AvatarSourceDictionary = "dictionary"; + + [CascadingParameter] private MudDialogInstance MudDialog { get; set; } = default!; + [Parameter] public IReadOnlyList AccountIds { get; set; } = Array.Empty(); + + private List accounts = new(); + private List imageDictionaryOptions = new(); + + private string avatarSource = AvatarSourceFixed; + private string selectedImageDictionaryName = string.Empty; + private byte[]? fixedAvatarBytes; + private string fixedAvatarFileName = "avatar.jpg"; + + private bool running; + private int processed; + private int success; + private int failed; + private readonly List results = new(); + + private bool IsFixedSource => string.Equals(avatarSource, AvatarSourceFixed, StringComparison.OrdinalIgnoreCase); + + protected override async Task OnInitializedAsync() + { + var ids = AccountIds?.Distinct().Where(x => x > 0).ToList() ?? new List(); + if (ids.Count == 0) + return; + + var all = await AccountManagement.GetAllAccountsAsync(); + accounts = all.Where(a => ids.Contains(a.Id)).OrderBy(a => a.Id).ToList(); + + var dictionaries = await DictionaryService.GetAllAsync(); + imageDictionaryOptions = dictionaries + .Where(x => x.IsEnabled && string.Equals(x.Type, DataDictionaryTypes.Image, StringComparison.OrdinalIgnoreCase)) + .OrderBy(x => x.Name, StringComparer.OrdinalIgnoreCase) + .Select(x => new ImageDictionaryOption(x.Name, GetReadModeText(x.ReadMode))) + .ToList(); + } + + private async Task OnAvatarChanged(IReadOnlyList files) + { + var file = files?.FirstOrDefault(); + if (file == null) + { + fixedAvatarBytes = null; + fixedAvatarFileName = "avatar.jpg"; + return; + } + + try + { + const long maxBytes = 20 * 1024 * 1024; + await using var stream = file.OpenReadStream(maxBytes); + using var memory = new MemoryStream(); + await stream.CopyToAsync(memory); + fixedAvatarBytes = memory.ToArray(); + fixedAvatarFileName = string.IsNullOrWhiteSpace(file.Name) ? "avatar.jpg" : file.Name.Trim(); + avatarSource = AvatarSourceFixed; + } + catch (Exception ex) + { + fixedAvatarBytes = null; + fixedAvatarFileName = "avatar.jpg"; + Snackbar.Add($"读取头像失败:{ex.Message}", Severity.Error); + } + } + + private async Task Start() + { + if (running) + return; + + if (accounts.Count == 0) + { + Snackbar.Add("未选择账号", Severity.Warning); + return; + } + + if (IsFixedSource) + { + if (fixedAvatarBytes is not { Length: > 0 }) + { + Snackbar.Add("请先上传固定头像", Severity.Warning); + return; + } + } + else if (string.IsNullOrWhiteSpace(selectedImageDictionaryName)) + { + Snackbar.Add("请选择图片字典", Severity.Warning); + return; + } + + var batchCheck = RiskService.CheckBatchAccounts(accounts); + if (batchCheck.HasRiskyAccounts) + { + var parameters = new DialogParameters + { + ["Message"] = $"检测到 {batchCheck.RiskyCount} 个风险账号", + ["DetailedMessage"] = batchCheck.GetRiskySummary(), + ["ShowRecommendations"] = true, + ["ShowExcludeOption"] = true, + ["RiskyAccounts"] = batchCheck.RiskyAccounts.ToList() + }; + var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Large }; + var dialog = DialogService.Show("批量操作风控警告", parameters, options); + var result = await dialog.Result; + + if (result?.Canceled ?? true) + return; + + if (result.Data is TelegramPanel.Core.Services.RiskWarningAction action && action == TelegramPanel.Core.Services.RiskWarningAction.ExcludeRisky) + { + accounts = batchCheck.SafeAccounts.ToList(); + if (accounts.Count == 0) + { + Snackbar.Add("排除风险账号后无剩余账号", Severity.Info); + return; + } + } + } + + var sourceSummary = IsFixedSource ? "固定头像" : $"图片字典变量 {{{selectedImageDictionaryName}}}"; + bool? confirm = await DialogService.ShowMessageBox( + "确认修改", + $"将对 {accounts.Count} 个账号批量修改头像(来源:{sourceSummary})。是否继续?", + yesText: "继续", cancelText: "取消"); + + if (confirm != true) + return; + + running = true; + processed = 0; + success = 0; + failed = 0; + results.Clear(); + + try + { + foreach (var account in accounts) + { + string source; + bool ok; + string? err; + + try + { + if (IsFixedSource) + { + source = fixedAvatarFileName; + await using var stream = new MemoryStream(fixedAvatarBytes!, writable: false); + (ok, err) = await AccountTelegramTools.UpdateProfilePhotoAsync(account.Id, stream, fixedAvatarFileName, CancellationToken.None); + } + else + { + var token = $"{{{selectedImageDictionaryName}}}"; + var asset = await TemplateRendering.ResolveImageTemplateAsync(token, CancellationToken.None); + source = $"{token} → {asset.FileName}"; + await using var stream = await AssetStorage.OpenReadAsync(asset.AssetPath, CancellationToken.None); + (ok, err) = await AccountTelegramTools.UpdateProfilePhotoAsync(account.Id, stream, asset.FileName, CancellationToken.None); + } + } + catch (Exception ex) + { + source = IsFixedSource ? fixedAvatarFileName : $"{{{selectedImageDictionaryName}}}"; + ok = false; + err = ex.Message; + } + + processed++; + if (ok) + success++; + else + failed++; + + results.Add(new ResultRow(account.DisplayPhone, source, ok, err)); + StateHasChanged(); + } + + Snackbar.Add($"头像批量修改完成:成功 {success}/{accounts.Count},失败 {failed}/{accounts.Count}", failed == 0 ? Severity.Success : Severity.Warning); + } + catch (Exception ex) + { + Snackbar.Add($"执行失败:{ex.Message}", Severity.Error); + } + finally + { + running = false; + } + } + + private static string GetReadModeText(string? readMode) + { + return string.Equals(readMode, DataDictionaryReadModes.Queue, StringComparison.OrdinalIgnoreCase) + ? "轮询" + : "随机"; + } + + private void Cancel() => MudDialog.Close(); + + private sealed record ImageDictionaryOption(string Name, string ReadMode) + { + public string DisplayText => $"{Name}({ReadMode})"; + } + + private sealed record ResultRow(string Phone, string Source, bool Success, string? Error); +} diff --git a/src/TelegramPanel.Web/Components/Dialogs/BatchChangeNicknameDialog.razor b/src/TelegramPanel.Web/Components/Dialogs/BatchChangeNicknameDialog.razor index 947a127..9966e90 100644 --- a/src/TelegramPanel.Web/Components/Dialogs/BatchChangeNicknameDialog.razor +++ b/src/TelegramPanel.Web/Components/Dialogs/BatchChangeNicknameDialog.razor @@ -2,30 +2,38 @@ @inject AccountManagementService AccountManagement @inject TelegramPanel.Core.Services.Telegram.AccountTelegramToolsService AccountTelegramTools @inject TelegramPanel.Core.Services.AccountRiskService RiskService +@inject TemplateRenderingService TemplateRendering +@inject DataDictionaryService DictionaryService @inject ISnackbar Snackbar @inject IDialogService DialogService +@using System.Text.RegularExpressions @using TelegramPanel.Data.Entities - 将对选中的账号批量修改昵称。支持两种模式:单个“批量昵称”(可自动追加手机号后 4 位),或“昵称列表”(按行轮询分配)。 + 将对选中的账号批量修改昵称。支持两种模式:单个“批量昵称”或“昵称列表”。这两个输入都支持 `{time}` 和文本字典变量。 账号数量:@accounts.Count + HelperText="单昵称模式:例如“测试号{city}”。若填写了“昵称列表”,则优先使用昵称列表。" /> + HelperText="每行一个昵称模板;按顺序分配给已选账号;昵称用完后从头轮询。每行同样支持 `{time}` 和文本字典变量。" /> 例如:昵称列表只有 1 行或轮询后重复时,为避免重复昵称可追加手机号后 4 位。 + @if (textDictionaryNames.Count > 0) + { + 可用文本变量:@string.Join("、", textDictionaryNames.Select(x => $"{{{x}}}")) + } + @if (running || results.Count > 0) { @@ -82,7 +90,11 @@ [CascadingParameter] private MudDialogInstance MudDialog { get; set; } = default!; [Parameter] public IReadOnlyList AccountIds { get; set; } = Array.Empty(); + private static readonly Regex TemplateTokenRegex = new(@"\{(?[a-zA-Z0-9_]+)\}", RegexOptions.Compiled); + + private readonly HashSet supportedTextTokens = new(StringComparer.OrdinalIgnoreCase); private List accounts = new(); + private List textDictionaryNames = new(); private string baseNickname = ""; private bool appendPhoneLast4 = true; @@ -103,6 +115,17 @@ var all = await AccountManagement.GetAllAccountsAsync(); accounts = all.Where(a => ids.Contains(a.Id)).OrderBy(a => a.Id).ToList(); + + var dictionaries = await DictionaryService.GetAllAsync(); + textDictionaryNames = dictionaries + .Where(x => x.IsEnabled && string.Equals(x.Type, DataDictionaryTypes.Text, StringComparison.OrdinalIgnoreCase)) + .Select(x => x.Name) + .OrderBy(x => x, StringComparer.OrdinalIgnoreCase) + .ToList(); + + supportedTextTokens.Clear(); + foreach (var item in textDictionaryNames) + supportedTextTokens.Add(item); } private async Task Start() @@ -125,7 +148,13 @@ return; } - // 批量风控检查:修改昵称是敏感操作 + var templates = useList ? nicknameList : new List { baseNickname }; + if (!ValidateTemplates(templates, out var templateError)) + { + Snackbar.Add(templateError ?? "昵称模板不合法", Severity.Warning); + return; + } + var batchCheck = RiskService.CheckBatchAccounts(accounts); if (batchCheck.HasRiskyAccounts) { @@ -141,12 +170,11 @@ var dialog = DialogService.Show("批量操作风控警告", parameters, options); var result = await dialog.Result; - if (result.Canceled) - return; // 用户取消操作 + if (result?.Canceled ?? true) + return; if (result.Data is TelegramPanel.Core.Services.RiskWarningAction action && action == TelegramPanel.Core.Services.RiskWarningAction.ExcludeRisky) { - // 排除风险账号后继续 accounts = batchCheck.SafeAccounts.ToList(); if (accounts.Count == 0) { @@ -154,7 +182,6 @@ return; } } - // else: 用户选择继续所有账号 } bool? confirm = await DialogService.ShowMessageBox( @@ -174,35 +201,51 @@ try { var baseUsedCount = new Dictionary(StringComparer.Ordinal); - foreach (var a in accounts) + foreach (var account in accounts) { - var baseName = useList ? nicknameList[processed % nicknameList.Count] : baseNickname; + var nicknameTemplate = useList ? nicknameList[processed % nicknameList.Count] : baseNickname; + string resolvedNickname; + try + { + resolvedNickname = (await TemplateRendering.RenderTextTemplateAsync(nicknameTemplate, CancellationToken.None)).Trim(); + } + catch (Exception ex) + { + processed++; + failed++; + results.Add(new ResultRow(account.DisplayPhone, string.Empty, false, $"昵称模板解析失败:{ex.Message}")); + StateHasChanged(); + continue; + } var needAppend = appendPhoneLast4; if (useList) { - baseName = (baseName ?? string.Empty).Trim(); - baseUsedCount.TryGetValue(baseName, out var used); - baseUsedCount[baseName] = used + 1; + baseUsedCount.TryGetValue(resolvedNickname, out var used); + baseUsedCount[resolvedNickname] = used + 1; needAppend = appendPhoneLast4WhenDuplicate && used > 0; } - var newNickname = BuildNickname(baseName, a.Phone, needAppend); + var newNickname = BuildNickname(resolvedNickname, account.Phone, needAppend); var (ok, err) = await AccountTelegramTools.UpdateUserProfileAsync( - accountId: a.Id, + accountId: account.Id, nickname: newNickname, bio: null, cancellationToken: CancellationToken.None); processed++; - if (ok) success++; else failed++; - results.Add(new ResultRow(a.DisplayPhone, newNickname, ok, err)); + if (ok) + success++; + else + failed++; + + results.Add(new ResultRow(account.DisplayPhone, newNickname, ok, err)); if (ok) { - a.Nickname = newNickname; - await AccountManagement.UpdateAccountAsync(a); + account.Nickname = newNickname; + await AccountManagement.UpdateAccountAsync(account); } StateHasChanged(); @@ -220,6 +263,37 @@ } } + private bool ValidateTemplates(IEnumerable templates, out string? error) + { + foreach (var template in templates) + { + if (!ValidateTemplate(template, out error)) + return false; + } + + error = null; + return true; + } + + private bool ValidateTemplate(string? template, out string? error) + { + error = null; + foreach (Match token in TemplateTokenRegex.Matches(template ?? string.Empty)) + { + var tokenName = token.Groups["name"].Value; + if (string.Equals(tokenName, "time", StringComparison.OrdinalIgnoreCase)) + continue; + + if (!supportedTextTokens.Contains(tokenName)) + { + error = $"不支持的文本变量:{{{tokenName}}}。请使用 {{time}} 或已启用的文本字典变量。"; + return false; + } + } + + return true; + } + private static string BuildNickname(string baseName, string phone, bool appendLast4) { var name = (baseName ?? string.Empty).Trim(); @@ -266,4 +340,3 @@ private sealed record ResultRow(string Phone, string NewNickname, bool Success, string? Error); } - diff --git a/src/TelegramPanel.Web/Components/Dialogs/BatchChangeUsernameDialog.razor b/src/TelegramPanel.Web/Components/Dialogs/BatchChangeUsernameDialog.razor index 1e93b99..7178677 100644 --- a/src/TelegramPanel.Web/Components/Dialogs/BatchChangeUsernameDialog.razor +++ b/src/TelegramPanel.Web/Components/Dialogs/BatchChangeUsernameDialog.razor @@ -2,9 +2,10 @@ @inject AccountManagementService AccountManagement @inject TelegramPanel.Core.Services.Telegram.AccountTelegramToolsService AccountTelegramTools @inject TelegramPanel.Core.Services.AccountRiskService RiskService +@inject TemplateRenderingService TemplateRendering +@inject DataDictionaryService DictionaryService @inject ISnackbar Snackbar @inject IDialogService DialogService -@using System.Security.Cryptography @using System.Text.RegularExpressions @using TelegramPanel.Data.Entities @@ -12,19 +13,18 @@ - 将对选中的账号批量修改用户名(t.me/xxx)。用户名模板支持变量: - - {number} / {letter} 表示 1 位随机数字/字母;{number3} 表示 3 位随机数字;{letter4} 表示 4 位随机字母。可组合使用。 - - - 示例:test_{number3}{letter4} → test_123abcd - + 将对选中的账号批量修改用户名(t.me/xxx)。用户名模板支持 `{time}` 和文本字典变量;批量执行时建议使用文本字典提供不同值,避免重复。 账号数量:@accounts.Count + HelperText="请输入用户名模板(不要带开头的 at 符号)。例如:tg_{city}_{time}" /> + + @if (textDictionaryNames.Count > 0) + { + 可用文本变量:@string.Join("、", textDictionaryNames.Select(x => $"{{{x}}}")) + } @if (running || results.Count > 0) { @@ -82,11 +82,12 @@ [CascadingParameter] private MudDialogInstance MudDialog { get; set; } = default!; [Parameter] public IReadOnlyList AccountIds { get; set; } = Array.Empty(); - private static readonly Regex SupportedTokenRegex = new(@"\{(?number|nuber|letter)(?\d*)\}", RegexOptions.Compiled | RegexOptions.IgnoreCase); - private static readonly Regex AnyTokenRegex = new(@"\{[^}]*\}", RegexOptions.Compiled); + private static readonly Regex TemplateTokenRegex = new(@"\{(?[a-zA-Z0-9_]+)\}", RegexOptions.Compiled); private static readonly Regex UsernameRegex = new(@"^[A-Za-z][A-Za-z0-9_]{4,31}$", RegexOptions.Compiled); + private readonly HashSet supportedTextTokens = new(StringComparer.OrdinalIgnoreCase); private List accounts = new(); + private List textDictionaryNames = new(); private string usernameTemplate = ""; private bool running; @@ -103,6 +104,17 @@ var all = await AccountManagement.GetAllAccountsAsync(); accounts = all.Where(a => ids.Contains(a.Id)).OrderBy(a => a.Id).ToList(); + + var dictionaries = await DictionaryService.GetAllAsync(); + textDictionaryNames = dictionaries + .Where(x => x.IsEnabled && string.Equals(x.Type, DataDictionaryTypes.Text, StringComparison.OrdinalIgnoreCase)) + .Select(x => x.Name) + .OrderBy(x => x, StringComparer.OrdinalIgnoreCase) + .ToList(); + + supportedTextTokens.Clear(); + foreach (var item in textDictionaryNames) + supportedTextTokens.Add(item); } private async Task Start() @@ -123,19 +135,18 @@ return; } - if (accounts.Count > 1 && !SupportedTokenRegex.IsMatch(template)) + if (accounts.Count > 1 && !TemplateTokenRegex.IsMatch(template)) { - Snackbar.Add("批量修改多个账号时,用户名模板需要包含至少一个随机变量(例如 {number3} / {letter4})", Severity.Warning); + Snackbar.Add("批量修改多个账号时,用户名模板至少需要包含一个变量(例如 {time} 或 {username_pool})", Severity.Warning); return; } - if (!ValidateTemplateTokens(template, out var templateErr)) + if (!ValidateTemplate(template, out var templateErr)) { Snackbar.Add(templateErr ?? "用户名模板不合法", Severity.Warning); return; } - // 批量风控检查:修改用户名是敏感操作 var batchCheck = RiskService.CheckBatchAccounts(accounts); if (batchCheck.HasRiskyAccounts) { @@ -151,7 +162,7 @@ var dialog = DialogService.Show("批量操作风控警告", parameters, options); var result = await dialog.Result; - if (result.Canceled) + if (result?.Canceled ?? true) return; if (result.Data is TelegramPanel.Core.Services.RiskWarningAction action && action == TelegramPanel.Core.Services.RiskWarningAction.ExcludeRisky) @@ -182,30 +193,48 @@ try { var generated = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var a in accounts) + foreach (var account in accounts) { - if (!TryGenerateUniqueUsername(template, generated, out var newUsername, out var genErr)) + string renderedTemplate; + try + { + renderedTemplate = await TemplateRendering.RenderTextTemplateAsync(template, CancellationToken.None); + } + catch (Exception ex) { processed++; failed++; - results.Add(new ResultRow(a.DisplayPhone, "", false, genErr ?? "生成用户名失败")); + results.Add(new ResultRow(account.DisplayPhone, string.Empty, false, $"用户名模板解析失败:{ex.Message}")); + StateHasChanged(); + continue; + } + + if (!TryPrepareUsername(renderedTemplate, generated, out var newUsername, out var prepareError)) + { + processed++; + failed++; + results.Add(new ResultRow(account.DisplayPhone, newUsername, false, prepareError ?? "生成用户名失败")); StateHasChanged(); continue; } var (ok, err, username) = await AccountTelegramTools.UpdateUsernameAsync( - accountId: a.Id, + accountId: account.Id, username: newUsername, cancellationToken: CancellationToken.None); processed++; - if (ok) success++; else failed++; - results.Add(new ResultRow(a.DisplayPhone, newUsername, ok, err)); + if (ok) + success++; + else + failed++; + + results.Add(new ResultRow(account.DisplayPhone, newUsername, ok, err)); if (ok) { - a.Username = username; - await AccountManagement.UpdateAccountAsync(a); + account.Username = username; + await AccountManagement.UpdateAccountAsync(account); } StateHasChanged(); @@ -223,27 +252,18 @@ } } - private static bool ValidateTemplateTokens(string template, out string? error) + private bool ValidateTemplate(string? template, out string? error) { error = null; - - var tokens = AnyTokenRegex.Matches(template); - foreach (Match t in tokens) + foreach (Match token in TemplateTokenRegex.Matches(template ?? string.Empty)) { - if (!SupportedTokenRegex.IsMatch(t.Value)) - { - error = $"不支持的变量:{t.Value}。仅支持 {{number}}/{{letter}}/{{numberN}}/{{letterN}}。"; - return false; - } - - var m = SupportedTokenRegex.Match(t.Value); - var lenStr = m.Groups["len"].Value; - if (string.IsNullOrWhiteSpace(lenStr)) + var tokenName = token.Groups["name"].Value; + if (string.Equals(tokenName, "time", StringComparison.OrdinalIgnoreCase)) continue; - if (!int.TryParse(lenStr, out var len) || len <= 0 || len > 32) + if (!supportedTextTokens.Contains(tokenName)) { - error = $"变量长度不合法:{t.Value}。长度需为 1-32。"; + error = $"不支持的文本变量:{{{tokenName}}}。请使用 {{time}} 或已启用的文本字典变量。"; return false; } } @@ -251,67 +271,28 @@ return true; } - private static bool TryGenerateUniqueUsername( - string template, + private static bool TryPrepareUsername( + string templateResult, HashSet reserved, out string username, out string? error) { - username = ""; + username = (templateResult ?? string.Empty).Trim().TrimStart('@'); error = null; - for (var i = 0; i < 25; i++) + if (!UsernameRegex.IsMatch(username)) { - var candidate = ExpandTemplate(template); - candidate = (candidate ?? string.Empty).Trim().TrimStart('@'); - - if (!UsernameRegex.IsMatch(candidate)) - { - error = "生成的用户名不符合 Telegram 规则:仅允许字母/数字/下划线,长度 5-32,且必须以字母开头。"; - return false; - } - - if (reserved.Add(candidate)) - { - username = candidate; - return true; - } + error = "生成的用户名不符合 Telegram 规则:仅允许字母/数字/下划线,长度 5-32,且必须以字母开头。"; + return false; } - error = "生成的用户名在本次批量操作中重复次数过多,请增加随机位数(例如 {number6} / {letter6})。"; - return false; - } - - private static string ExpandTemplate(string template) - { - return SupportedTokenRegex.Replace(template, m => + if (!reserved.Add(username)) { - var kind = (m.Groups["kind"].Value ?? string.Empty).ToLowerInvariant(); - if (kind == "nuber") - kind = "number"; - var lenStr = m.Groups["len"].Value; - var len = string.IsNullOrWhiteSpace(lenStr) ? 1 : int.Parse(lenStr); + error = "本次批量操作生成了重复用户名,请调整文本字典内容或模板。"; + return false; + } - return kind == "number" - ? RandomDigits(len) - : RandomLetters(len); - }); - } - - private static string RandomDigits(int length) - { - var chars = new char[length]; - for (var i = 0; i < length; i++) - chars[i] = (char)('0' + RandomNumberGenerator.GetInt32(0, 10)); - return new string(chars); - } - - private static string RandomLetters(int length) - { - var chars = new char[length]; - for (var i = 0; i < length; i++) - chars[i] = (char)('a' + RandomNumberGenerator.GetInt32(0, 26)); - return new string(chars); + return true; } private void Cancel() => MudDialog.Close(); diff --git a/src/TelegramPanel.Web/Components/Pages/AccountImport.razor b/src/TelegramPanel.Web/Components/Pages/AccountImport.razor index ad04068..6271ad5 100644 --- a/src/TelegramPanel.Web/Components/Pages/AccountImport.razor +++ b/src/TelegramPanel.Web/Components/Pages/AccountImport.razor @@ -213,6 +213,7 @@ 批量换绑邮箱(找回+登录)(Cloud Mail)(已选) 批量修改分类(已选) 批量改昵称(已选) + 批量改头像(已选) 批量改用户名(已选) 批量改Bio(已选) @@ -682,6 +683,29 @@ await ReloadImportedAccountsAsync(); } + private async Task OpenBatchChangeAvatar() + { + if (Busy) + return; + + var ids = selectedAccounts.Select(a => a.Id).Distinct().ToList(); + if (ids.Count == 0) + { + Snackbar.Add("请先选择账号", Severity.Info); + return; + } + + var parameters = new DialogParameters + { + ["AccountIds"] = ids + }; + var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Medium, FullWidth = true }; + var dialog = DialogService.Show("批量修改头像", parameters, options)!; + var result = await dialog.Result; + if (result is { Canceled: false }) + await ReloadImportedAccountsAsync(); + } + private async Task OpenBatchChangeUsername() { if (Busy) diff --git a/src/TelegramPanel.Web/Components/Pages/Accounts.razor b/src/TelegramPanel.Web/Components/Pages/Accounts.razor index 4470f7c..c0e7ef8 100644 --- a/src/TelegramPanel.Web/Components/Pages/Accounts.razor +++ b/src/TelegramPanel.Web/Components/Pages/Accounts.razor @@ -84,6 +84,7 @@ 批量踢出所有其他设备(已选) 批量修改分类(已选) 批量改昵称(已选) + 批量改头像(已选) 批量改用户名(已选) 批量改Bio(已选) 删除已选 @@ -522,6 +523,29 @@ await ReloadTable(); } + private async Task OpenBatchChangeAvatar() + { + if (loading) + return; + + var ids = selectedAccounts.Select(a => a.Id).Distinct().ToList(); + if (ids.Count == 0) + { + Snackbar.Add("请先选择账号", Severity.Info); + return; + } + + var parameters = new DialogParameters + { + ["AccountIds"] = ids + }; + var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Medium, FullWidth = true }; + var dialog = DialogService.Show("批量修改头像", parameters, options)!; + var result = await dialog.Result; + if (result is { Canceled: false }) + await ReloadTable(); + } + private async Task OpenBatchChangeUsername() { if (loading) diff --git a/src/TelegramPanel.Web/Components/Pages/DataDictionaries.razor b/src/TelegramPanel.Web/Components/Pages/DataDictionaries.razor index 6ffa13b..ea7872d 100644 --- a/src/TelegramPanel.Web/Components/Pages/DataDictionaries.razor +++ b/src/TelegramPanel.Web/Components/Pages/DataDictionaries.razor @@ -8,6 +8,12 @@ 数据字典管理 + + 当前系统内置变量:{time},格式为 yyyyMMddHHmmss,例如 20260310143059。 + 它不是 Unix 时间戳,而是可直接阅读的时间字符串。 + 文本字段通常支持 {time} 和 {字典名};图片字段通常只能使用单个图片字典变量,例如 {avatar}。 + + diff --git a/src/TelegramPanel.Web/Services/TemplateRenderingService.cs b/src/TelegramPanel.Web/Services/TemplateRenderingService.cs index 26d9bab..b56c54e 100644 --- a/src/TelegramPanel.Web/Services/TemplateRenderingService.cs +++ b/src/TelegramPanel.Web/Services/TemplateRenderingService.cs @@ -1,5 +1,6 @@ using System.Text; using System.Text.RegularExpressions; +using TelegramPanel.Data.Entities; namespace TelegramPanel.Web.Services; @@ -16,6 +17,47 @@ public sealed class TemplateRenderingService _dataDictionaryService = dataDictionaryService; } + public IReadOnlyList ExtractTokenNames(string? template) + { + if (string.IsNullOrWhiteSpace(template)) + return Array.Empty(); + + var names = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (Match match in TokenRegex.Matches(template)) + { + if (!match.Success) + continue; + + var tokenName = (match.Groups["name"].Value ?? string.Empty).Trim(); + if (tokenName.Length == 0 || !seen.Add(tokenName)) + continue; + + names.Add(tokenName); + } + + return names; + } + + public async Task ValidateTextTemplateAsync(string? template, CancellationToken cancellationToken = default) + { + foreach (var tokenName in ExtractTokenNames(template)) + { + if (string.Equals(tokenName, "time", StringComparison.OrdinalIgnoreCase)) + continue; + + await EnsureDictionaryAvailableAsync(tokenName, DataDictionaryTypes.Text, cancellationToken); + } + } + + public async Task ValidateImageTemplateAsync(string? tokenExpression, CancellationToken cancellationToken = default) + { + var tokenName = ExtractSingleTokenName(tokenExpression) + ?? throw new InvalidOperationException("图片变量必须是单个字典变量,例如 {avatar}"); + + await EnsureDictionaryAvailableAsync(tokenName, DataDictionaryTypes.Image, cancellationToken); + } + public async Task RenderTextTemplateAsync(string template, CancellationToken cancellationToken = default) { template = template ?? string.Empty; @@ -75,4 +117,33 @@ public sealed class TemplateRenderingService return await _dataDictionaryService.ResolveTextValueAsync(tokenName, cancellationToken); } + + private async Task EnsureDictionaryAvailableAsync(string tokenName, string expectedType, CancellationToken cancellationToken) + { + var dictionary = await _dataDictionaryService.GetByNameAsync(tokenName, cancellationToken) + ?? throw new InvalidOperationException($"未找到变量:{{{tokenName}}}"); + + if (!dictionary.IsEnabled) + throw new InvalidOperationException($"变量已停用:{{{tokenName}}}"); + + if (!string.Equals(dictionary.Type, expectedType, StringComparison.OrdinalIgnoreCase)) + { + var expectedName = string.Equals(expectedType, DataDictionaryTypes.Image, StringComparison.OrdinalIgnoreCase) + ? "图片字典" + : "文本字典"; + throw new InvalidOperationException($"变量类型不匹配:{{{tokenName}}} 需要使用{expectedName}"); + } + + var hasAvailableItem = string.Equals(expectedType, DataDictionaryTypes.Image, StringComparison.OrdinalIgnoreCase) + ? dictionary.Items.Any(x => x.IsEnabled && !string.IsNullOrWhiteSpace(x.AssetPath)) + : dictionary.Items.Any(x => x.IsEnabled && !string.IsNullOrWhiteSpace(x.TextValue)); + + if (!hasAvailableItem) + { + var valueName = string.Equals(expectedType, DataDictionaryTypes.Image, StringComparison.OrdinalIgnoreCase) + ? "图片" + : "内容"; + throw new InvalidOperationException($"变量没有可用{valueName}:{{{tokenName}}}"); + } + } } From 017fc329fa376289db9179a00727bb8e6f1cdcbe Mon Sep 17 00:00:00 2001 From: meoacgx Date: Tue, 10 Mar 2026 02:13:14 +0800 Subject: [PATCH 05/13] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E6=98=B5?= =?UTF-8?q?=E7=A7=B0=E6=89=B9=E9=87=8F=E6=A8=A1=E6=9D=BF=E5=B9=B6=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E6=B4=BB=E8=B7=83=E4=BB=BB=E5=8A=A1=E5=AD=97=E5=85=B8?= =?UTF-8?q?=E5=8F=98=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Dialogs/BatchChangeNicknameDialog.razor | 53 ++++++++-------- .../Dialogs/UserChatActiveTaskEditor.razor | 55 +++++++++++----- .../Services/UserChatActiveTaskHandler.cs | 62 ++++++++++++++++++- 3 files changed, 129 insertions(+), 41 deletions(-) diff --git a/src/TelegramPanel.Web/Components/Dialogs/BatchChangeNicknameDialog.razor b/src/TelegramPanel.Web/Components/Dialogs/BatchChangeNicknameDialog.razor index 9966e90..aa60aac 100644 --- a/src/TelegramPanel.Web/Components/Dialogs/BatchChangeNicknameDialog.razor +++ b/src/TelegramPanel.Web/Components/Dialogs/BatchChangeNicknameDialog.razor @@ -13,20 +13,23 @@ - 将对选中的账号批量修改昵称。支持两种模式:单个“批量昵称”或“昵称列表”。这两个输入都支持 `{time}` 和文本字典变量。 + 将对选中的账号批量修改昵称。只需要填写“昵称模板”,支持一行一个模板,并支持多个变量组合,例如:{diqu}歌神{geshou}。 账号数量:@accounts.Count - - + - - + - 例如:昵称列表只有 1 行或轮询后重复时,为避免重复昵称可追加手机号后 4 位。 + 当同一批次生成出重复昵称时,自动在后面追加该账号手机号后 4 位。 @if (textDictionaryNames.Count > 0) @@ -96,9 +99,7 @@ private List accounts = new(); private List textDictionaryNames = new(); - private string baseNickname = ""; - private bool appendPhoneLast4 = true; - private string nicknameLines = ""; + private string nicknameTemplatesText = string.Empty; private bool appendPhoneLast4WhenDuplicate = true; private bool running; @@ -139,16 +140,13 @@ return; } - var nicknameList = ParseNicknameLines(nicknameLines); - var useList = nicknameList.Count > 0; - - if (!useList && string.IsNullOrWhiteSpace(baseNickname)) + var templates = ParseNicknameLines(nicknameTemplatesText); + if (templates.Count == 0) { - Snackbar.Add("批量昵称不能为空", Severity.Warning); + Snackbar.Add("请至少填写一行昵称模板", Severity.Warning); return; } - var templates = useList ? nicknameList : new List { baseNickname }; if (!ValidateTemplates(templates, out var templateError)) { Snackbar.Add(templateError ?? "昵称模板不合法", Severity.Warning); @@ -200,10 +198,10 @@ try { - var baseUsedCount = new Dictionary(StringComparer.Ordinal); + var usedNicknames = new Dictionary(StringComparer.Ordinal); foreach (var account in accounts) { - var nicknameTemplate = useList ? nicknameList[processed % nicknameList.Count] : baseNickname; + var nicknameTemplate = templates[processed % templates.Count]; string resolvedNickname; try { @@ -218,15 +216,20 @@ continue; } - var needAppend = appendPhoneLast4; - if (useList) + if (string.IsNullOrWhiteSpace(resolvedNickname)) { - baseUsedCount.TryGetValue(resolvedNickname, out var used); - baseUsedCount[resolvedNickname] = used + 1; - needAppend = appendPhoneLast4WhenDuplicate && used > 0; + processed++; + failed++; + results.Add(new ResultRow(account.DisplayPhone, string.Empty, false, "生成的昵称为空,请检查模板或字典内容")); + StateHasChanged(); + continue; } - var newNickname = BuildNickname(resolvedNickname, account.Phone, needAppend); + usedNicknames.TryGetValue(resolvedNickname, out var usedCount); + usedNicknames[resolvedNickname] = usedCount + 1; + + var shouldAppendPhoneTail = appendPhoneLast4WhenDuplicate && usedCount > 0; + var newNickname = BuildNickname(resolvedNickname, account.Phone, shouldAppendPhoneTail); var (ok, err) = await AccountTelegramTools.UpdateUserProfileAsync( accountId: account.Id, diff --git a/src/TelegramPanel.Web/Components/Dialogs/UserChatActiveTaskEditor.razor b/src/TelegramPanel.Web/Components/Dialogs/UserChatActiveTaskEditor.razor index 9e7d4aa..b7f9538 100644 --- a/src/TelegramPanel.Web/Components/Dialogs/UserChatActiveTaskEditor.razor +++ b/src/TelegramPanel.Web/Components/Dialogs/UserChatActiveTaskEditor.razor @@ -1,4 +1,6 @@ @inject AccountCategoryManagementService CategoryManagement +@inject DataDictionaryService DictionaryService +@inject TemplateRenderingService TemplateRendering @using System.Text.Json @using TelegramPanel.Data.Entities @using TelegramPanel.Modules @@ -8,6 +10,7 @@ 持续活跃任务会在后台循环执行并按间隔发送内容。MaxMessages=0 表示持续运行,直到你在任务中心手动取消。 + 词典消息现在支持 {time} 和文本字典变量;任务仍会先按“随机 / 队列”模式选中一行,再解析其中变量后发送。 @if (loading) @@ -36,14 +39,23 @@ Variant="Variant.Outlined" Lines="5" Immediate="true" - HelperText="支持:用户名(可带 at 前缀)、https://t.me/xxx、频道ID(如 -1001234567890 或 1234567890),如私密群组无法复制链接可登录Telegram Web面板打开群组聊天界面查看链接,数字就是ID" /> + HelperText="支持:用户名(可带 at 前缀)、https://t.me/xxx、频道ID(如 -1001234567890 或 1234567890),如私密群组无法复制链接可登录 Telegram Web 面板打开群组聊天界面查看链接,数字就是 ID" /> + HelperText="支持 {time} 和文本字典变量。任务会先按“随机 / 队列”模式选中一行,再解析变量后发送。" /> + + @if (textDictionaryNames.Count > 0) + { + 可用文本变量:{time}、@string.Join("、", textDictionaryNames.Select(x => $"{{{x}}}")) + } + else + { + 当前可用内置变量:{time} + } @@ -93,15 +105,16 @@ [Parameter] public string? InitialConfigJson { get; set; } private List categories = new(); + private List textDictionaryNames = new(); private bool loading = true; private bool initializedFromConfig; private IEnumerable selectedCategoryIds = Array.Empty(); - private string targetsText = ""; - private string dictionaryText = ""; + private string targetsText = string.Empty; + private string dictionaryText = string.Empty; private int delayMinMs = 15; private int delayMaxMs = 45; - private int maxMessages = 0; + private int maxMessages; private string accountMode = UserChatActiveTaskModes.Random; private string targetMode = UserChatActiveTaskModes.Queue; @@ -118,17 +131,19 @@ .OrderBy(x => x.Name, StringComparer.OrdinalIgnoreCase) .ToList(); - if (!selectedCategoryIds.Any()) - { - var firstCategoryId = categories.FirstOrDefault()?.Id ?? 0; - selectedCategoryIds = firstCategoryId > 0 ? new[] { firstCategoryId } : Array.Empty(); - } + textDictionaryNames = (await DictionaryService.GetAllAsync()) + .Where(x => x.IsEnabled && string.Equals(x.Type, DataDictionaryTypes.Text, StringComparison.OrdinalIgnoreCase)) + .Where(x => x.Items.Any(i => i.IsEnabled && !string.IsNullOrWhiteSpace(i.TextValue))) + .Select(x => x.Name) + .OrderBy(x => x, StringComparer.OrdinalIgnoreCase) + .ToList(); } finally { loading = false; - await PushDraftAsync(); } + + await PushDraftAsync(); } private void ApplyInitialConfigIfNeeded() @@ -148,10 +163,7 @@ if (cfg == null) return; - var categoryIds = NormalizeCategoryIds(cfg.CategoryIds, cfg.CategoryId); - if (categoryIds.Count > 0) - selectedCategoryIds = categoryIds; - + selectedCategoryIds = NormalizeCategoryIds(cfg.CategoryIds, cfg.CategoryId); targetsText = string.Join(Environment.NewLine, cfg.Targets ?? new List()); dictionaryText = string.Join(Environment.NewLine, cfg.Dictionary ?? new List()); delayMinMs = ConvertMillisecondsToSeconds(cfg.DelayMinMs); @@ -215,6 +227,19 @@ return; } + foreach (var template in dictionary) + { + try + { + await TemplateRendering.ValidateTextTemplateAsync(template); + } + catch (Exception ex) + { + await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, $"词典消息模板无效:{ex.Message}")); + return; + } + } + if (delayMinMs < 0 || delayMaxMs < 0) { await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "间隔不能为负数")); diff --git a/src/TelegramPanel.Web/Services/UserChatActiveTaskHandler.cs b/src/TelegramPanel.Web/Services/UserChatActiveTaskHandler.cs index 12532fe..ca976a8 100644 --- a/src/TelegramPanel.Web/Services/UserChatActiveTaskHandler.cs +++ b/src/TelegramPanel.Web/Services/UserChatActiveTaskHandler.cs @@ -21,6 +21,7 @@ public sealed class UserChatActiveTaskHandler : IModuleTaskHandler var taskManagement = host.Services.GetRequiredService(); var accountManagement = host.Services.GetRequiredService(); var accountTools = host.Services.GetRequiredService(); + var templateRendering = host.Services.GetRequiredService(); var config = DeserializeConfig(host.Config); ValidateAndNormalizeConfig(config); @@ -116,7 +117,7 @@ public sealed class UserChatActiveTaskHandler : IModuleTaskHandler var targetSlot = accountSlot.Targets[targetIdx]; var messageIdx = SelectIndex(config.MessageMode, config.Dictionary.Count, ref messageQueueIndex); - var text = config.Dictionary[messageIdx]; + var textTemplate = config.Dictionary[messageIdx]; if (!await host.IsStillRunningAsync(cancellationToken)) { @@ -124,6 +125,65 @@ public sealed class UserChatActiveTaskHandler : IModuleTaskHandler break; } + string text; + try + { + text = (await templateRendering.RenderTextTemplateAsync(textTemplate, cancellationToken)).Trim(); + } + catch (Exception ex) + { + completed++; + failed++; + var hadTemplateFailure = true; + AddFailure(config, accountSlot.Account, targetSlot.RawTarget, $"词典模板解析失败:{ex.Message}"); + await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(config)); + + if (ShouldPersistProgress(completed, hadTemplateFailure, lastProgressPersistAt)) + { + await host.UpdateProgressAsync(completed, failed, cancellationToken); + lastProgressPersistAt = DateTime.UtcNow; + } + + if (config.MaxMessages > 0 && completed >= config.MaxMessages) + break; + + var templateFailDelayMs = NextDelayMilliseconds(config.DelayMinMs, config.DelayMaxMs); + if (templateFailDelayMs > 0 && !await DelayWithPauseCheckAsync(host, templateFailDelayMs, cancellationToken)) + { + config.Canceled = true; + break; + } + + continue; + } + + if (text.Length == 0) + { + completed++; + failed++; + var hadEmptyMessageFailure = true; + AddFailure(config, accountSlot.Account, targetSlot.RawTarget, "词典模板解析结果为空,无法发送"); + await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(config)); + + if (ShouldPersistProgress(completed, hadEmptyMessageFailure, lastProgressPersistAt)) + { + await host.UpdateProgressAsync(completed, failed, cancellationToken); + lastProgressPersistAt = DateTime.UtcNow; + } + + if (config.MaxMessages > 0 && completed >= config.MaxMessages) + break; + + var emptyDelayMs = NextDelayMilliseconds(config.DelayMinMs, config.DelayMaxMs); + if (emptyDelayMs > 0 && !await DelayWithPauseCheckAsync(host, emptyDelayMs, cancellationToken)) + { + config.Canceled = true; + break; + } + + continue; + } + var send = await accountTools.SendMessageToResolvedChatAsync( accountSlot.Account.Id, targetSlot.Resolved, From 58273b12cde9f2d9bb91ad75475efaf3d114a33b Mon Sep 17 00:00:00 2001 From: meoacgx Date: Tue, 10 Mar 2026 03:23:43 +0800 Subject: [PATCH 06/13] =?UTF-8?q?fix:=20=E5=85=81=E8=AE=B8=E9=87=8D?= =?UTF-8?q?=E5=A4=8D=E5=8F=98=E9=87=8F=E7=8B=AC=E7=AB=8B=E8=A7=A3=E6=9E=90?= =?UTF-8?q?=E5=8F=96=E5=80=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Services/TemplateRenderingService.cs | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/TelegramPanel.Web/Services/TemplateRenderingService.cs b/src/TelegramPanel.Web/Services/TemplateRenderingService.cs index b56c54e..21d318a 100644 --- a/src/TelegramPanel.Web/Services/TemplateRenderingService.cs +++ b/src/TelegramPanel.Web/Services/TemplateRenderingService.cs @@ -65,7 +65,6 @@ public sealed class TemplateRenderingService if (matches.Count == 0) return template; - var cache = new Dictionary(StringComparer.OrdinalIgnoreCase); var builder = new StringBuilder(); var lastIndex = 0; @@ -76,12 +75,7 @@ public sealed class TemplateRenderingService builder.Append(template, lastIndex, match.Index - lastIndex); var tokenName = match.Groups["name"].Value; - if (!cache.TryGetValue(tokenName, out var resolved)) - { - resolved = await ResolveTextTokenAsync(tokenName, cancellationToken); - cache[tokenName] = resolved; - } - + var resolved = await ResolveTextTokenAsync(tokenName, cancellationToken); builder.Append(resolved); lastIndex = match.Index + match.Length; } From dfbf86230d066fa67d75b6bd21b116ccf288a10a Mon Sep 17 00:00:00 2001 From: meoacgx Date: Tue, 10 Mar 2026 03:24:44 +0800 Subject: [PATCH 07/13] =?UTF-8?q?chore:=20=E5=88=A0=E9=99=A4=20message=20r?= =?UTF-8?q?eport=20=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TelegramPanel.sln | 18 - .../MessageReportTaskEditor.razor | 312 ----------- .../MessageReportTaskHandler.cs | 312 ----------- .../MessageReportTaskModels.cs | 266 ---------- .../MessageReportTaskModule.cs | 56 -- .../MessageReportTaskRerunBuilder.cs | 75 --- .../MessageReportTaskTelegramService.cs | 419 --------------- ...TelegramPanel.Modules.MessageReport.csproj | 23 - .../_Imports.razor | 4 - .../manifest.json | 11 - .../Dialogs/MessageReportTaskEditor.razor | 315 ------------ .../MessageReportTaskModule.cs | 58 --- .../Services/MessageReportTaskHandler.cs | 429 ---------------- .../Services/MessageReportTaskModels.cs | 86 ---- .../Services/MessageReportTaskRerunBuilder.cs | 132 ----- .../Services/TelegramMessageReportService.cs | 483 ------------------ ...gramPanel.Modules.MessageReportTask.csproj | 33 -- .../_Imports.razor | 5 - .../manifest.json | 13 - 19 files changed, 3050 deletions(-) delete mode 100644 src/TelegramPanel.Modules.MessageReport/MessageReportTaskEditor.razor delete mode 100644 src/TelegramPanel.Modules.MessageReport/MessageReportTaskHandler.cs delete mode 100644 src/TelegramPanel.Modules.MessageReport/MessageReportTaskModels.cs delete mode 100644 src/TelegramPanel.Modules.MessageReport/MessageReportTaskModule.cs delete mode 100644 src/TelegramPanel.Modules.MessageReport/MessageReportTaskRerunBuilder.cs delete mode 100644 src/TelegramPanel.Modules.MessageReport/MessageReportTaskTelegramService.cs delete mode 100644 src/TelegramPanel.Modules.MessageReport/TelegramPanel.Modules.MessageReport.csproj delete mode 100644 src/TelegramPanel.Modules.MessageReport/_Imports.razor delete mode 100644 src/TelegramPanel.Modules.MessageReport/manifest.json delete mode 100644 src/TelegramPanel.Modules.MessageReportTask/Components/Dialogs/MessageReportTaskEditor.razor delete mode 100644 src/TelegramPanel.Modules.MessageReportTask/MessageReportTaskModule.cs delete mode 100644 src/TelegramPanel.Modules.MessageReportTask/Services/MessageReportTaskHandler.cs delete mode 100644 src/TelegramPanel.Modules.MessageReportTask/Services/MessageReportTaskModels.cs delete mode 100644 src/TelegramPanel.Modules.MessageReportTask/Services/MessageReportTaskRerunBuilder.cs delete mode 100644 src/TelegramPanel.Modules.MessageReportTask/Services/TelegramMessageReportService.cs delete mode 100644 src/TelegramPanel.Modules.MessageReportTask/TelegramPanel.Modules.MessageReportTask.csproj delete mode 100644 src/TelegramPanel.Modules.MessageReportTask/_Imports.razor delete mode 100644 src/TelegramPanel.Modules.MessageReportTask/manifest.json diff --git a/TelegramPanel.sln b/TelegramPanel.sln index a899d77..228ca1e 100644 --- a/TelegramPanel.sln +++ b/TelegramPanel.sln @@ -10,12 +10,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TelegramPanel.Data", "src\T EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TelegramPanel.Modules.Abstractions", "src\TelegramPanel.Modules.Abstractions\TelegramPanel.Modules.Abstractions.csproj", "{D47E13D1-8E3C-4F40-9B6D-4B8E17A8C5F1}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{FDD46A28-5D10-4E16-A8B7-9C8612500D02}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TelegramPanel.Modules.MessageReport", "src\TelegramPanel.Modules.MessageReport\TelegramPanel.Modules.MessageReport.csproj", "{21B1CE10-B6E1-46FB-B580-16065D18EDD3}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TelegramPanel.Modules.MessageReportTask", "src\TelegramPanel.Modules.MessageReportTask\TelegramPanel.Modules.MessageReportTask.csproj", "{E48333AF-2411-4219-AD2F-6ABF45960100}" -EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -38,17 +32,5 @@ Global {D47E13D1-8E3C-4F40-9B6D-4B8E17A8C5F1}.Debug|Any CPU.Build.0 = Debug|Any CPU {D47E13D1-8E3C-4F40-9B6D-4B8E17A8C5F1}.Release|Any CPU.ActiveCfg = Release|Any CPU {D47E13D1-8E3C-4F40-9B6D-4B8E17A8C5F1}.Release|Any CPU.Build.0 = Release|Any CPU - {21B1CE10-B6E1-46FB-B580-16065D18EDD3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {21B1CE10-B6E1-46FB-B580-16065D18EDD3}.Debug|Any CPU.Build.0 = Debug|Any CPU - {21B1CE10-B6E1-46FB-B580-16065D18EDD3}.Release|Any CPU.ActiveCfg = Release|Any CPU - {21B1CE10-B6E1-46FB-B580-16065D18EDD3}.Release|Any CPU.Build.0 = Release|Any CPU - {E48333AF-2411-4219-AD2F-6ABF45960100}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {E48333AF-2411-4219-AD2F-6ABF45960100}.Debug|Any CPU.Build.0 = Debug|Any CPU - {E48333AF-2411-4219-AD2F-6ABF45960100}.Release|Any CPU.ActiveCfg = Release|Any CPU - {E48333AF-2411-4219-AD2F-6ABF45960100}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(NestedProjects) = preSolution - {21B1CE10-B6E1-46FB-B580-16065D18EDD3} = {FDD46A28-5D10-4E16-A8B7-9C8612500D02} - {E48333AF-2411-4219-AD2F-6ABF45960100} = {FDD46A28-5D10-4E16-A8B7-9C8612500D02} EndGlobalSection EndGlobal diff --git a/src/TelegramPanel.Modules.MessageReport/MessageReportTaskEditor.razor b/src/TelegramPanel.Modules.MessageReport/MessageReportTaskEditor.razor deleted file mode 100644 index 52bcda7..0000000 --- a/src/TelegramPanel.Modules.MessageReport/MessageReportTaskEditor.razor +++ /dev/null @@ -1,312 +0,0 @@ -@using System.Text.Json -@using TelegramPanel.Core.Services -@using TelegramPanel.Data.Entities -@inject AccountCategoryManagementService CategoryManagement -@inject AccountManagementService AccountManagement - - - - 该任务会按账号分类轮流举报指定消息。MaxReports=0 表示持续运行直到你在任务中心手动暂停/停止;如果在外层选择 Cron 计划,建议配合有限次数使用。 - - - @if (loading) - { - - 正在加载账号分类... - } - else - { - - @foreach (var category in categories) - { - @category.Name - } - - - - 当前选中分类下可用账号数:@selectedAccountCount - - - - - - - - - - - - - - - - - - 垃圾/广告 - 暴力 - 色情/成人 - 儿童/未成年相关 - 版权/侵权 - 隐私/个人信息 - 毒品/违禁药 - 诈骗/冒充/虚假 - 其他 - 自动(取 Telegram 返回的首项) - - - - - - - - 队列循环 - 随机 - - } - - -@code { - [Parameter] public ModuleTaskDraft Draft { get; set; } - [Parameter] public EventCallback DraftChanged { get; set; } - [Parameter] public string? InitialConfigJson { get; set; } - - private List categories = new(); - private List activeAccounts = new(); - private bool loading = true; - private bool initializedFromConfig; - - private IEnumerable selectedCategoryIds = Array.Empty(); - private string messageReferencesText = string.Empty; - private int delayMinSeconds = 15; - private int delayMaxSeconds = 45; - private int maxReports; - private string reportType = MessageReportTaskReportTypes.Spam; - private string optionKeywordsText = string.Empty; - private string commentsText = string.Empty; - private string commentMode = MessageReportTaskModes.Queue; - - private int runtimeCompletedAttempts; - private int runtimeFailedAttempts; - private int runtimeAccountCursor; - private int runtimeMessageCursor; - private int runtimeCommentCursor; - private List runtimeRecentFailures = new(); - - private int selectedAccountCount => activeAccounts.Count(x => - x.IsActive - && x.UserId > 0 - && x.CategoryId.HasValue - && selectedCategoryIds.Contains(x.CategoryId.Value) - && x.Category?.ExcludeFromOperations != true); - - protected override async Task OnInitializedAsync() - { - ApplyInitialConfigIfNeeded(); - - try - { - var categoriesTask = CategoryManagement.GetAllCategoriesAsync(); - var accountsTask = AccountManagement.GetAllAccountsAsync(); - await Task.WhenAll(categoriesTask, accountsTask); - - categories = categoriesTask.Result - .Where(x => !x.ExcludeFromOperations) - .OrderBy(x => x.Name, StringComparer.OrdinalIgnoreCase) - .ToList(); - - activeAccounts = accountsTask.Result.ToList(); - - if (!selectedCategoryIds.Any()) - { - var firstCategoryId = categories.FirstOrDefault()?.Id ?? 0; - selectedCategoryIds = firstCategoryId > 0 ? new[] { firstCategoryId } : Array.Empty(); - } - } - finally - { - loading = false; - await PushDraftAsync(); - } - } - - private void ApplyInitialConfigIfNeeded() - { - if (initializedFromConfig) - return; - - initializedFromConfig = true; - var raw = (InitialConfigJson ?? string.Empty).Trim(); - if (raw.Length == 0) - return; - - try - { - var cfg = JsonSerializer.Deserialize(raw); - if (cfg == null) - return; - - var categoryIds = MessageReportTaskInputHelper.NormalizeCategoryIds(cfg.CategoryIds, cfg.CategoryId); - if (categoryIds.Count > 0) - selectedCategoryIds = categoryIds; - - messageReferencesText = string.Join(Environment.NewLine, cfg.MessageReferences ?? new List()); - delayMinSeconds = ConvertMillisecondsToSeconds(cfg.DelayMinMs); - delayMaxSeconds = ConvertMillisecondsToSeconds(cfg.DelayMaxMs); - maxReports = Math.Max(0, cfg.MaxReports); - reportType = MessageReportTaskReportTypes.Normalize(cfg.ReportType); - optionKeywordsText = string.Join(Environment.NewLine, cfg.OptionKeywords ?? new List()); - commentsText = string.Join(Environment.NewLine, cfg.Comments ?? new List()); - commentMode = MessageReportTaskModes.Normalize(cfg.CommentMode); - - runtimeCompletedAttempts = Math.Max(0, cfg.CompletedAttempts); - runtimeFailedAttempts = Math.Max(0, cfg.FailedAttempts); - runtimeAccountCursor = Math.Max(0, cfg.AccountCursor); - runtimeMessageCursor = Math.Max(0, cfg.MessageCursor); - runtimeCommentCursor = Math.Max(0, cfg.CommentCursor); - runtimeRecentFailures = cfg.RecentFailures?.TakeLast(20).ToList() ?? new List(); - } - catch - { - } - } - - private async Task OnSelectedCategoriesChanged(IEnumerable values) - { - selectedCategoryIds = MessageReportTaskInputHelper.NormalizeCategoryIds(values, 0); - await OnInputsChanged(); - } - - private async Task OnInputsChanged() - { - await PushDraftAsync(); - } - - private async Task PushDraftAsync() - { - var selectedIds = MessageReportTaskInputHelper.NormalizeCategoryIds(selectedCategoryIds, 0); - var selectedCategories = categories.Where(x => selectedIds.Contains(x.Id)).ToList(); - var messageReferences = MessageReportTaskInputHelper.ParseMultilineText(messageReferencesText, distinct: true); - var optionKeywords = MessageReportTaskInputHelper.ParseMultilineText(optionKeywordsText, distinct: true); - var comments = MessageReportTaskInputHelper.ParseMultilineText(commentsText, distinct: false); - - if (loading) - { - await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "账号分类加载中")); - return; - } - - if (categories.Count == 0) - { - await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "没有可用账号分类(或都被标记为排除操作)")); - return; - } - - if (selectedCategories.Count == 0) - { - await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "请至少选择一个执行账号分类")); - return; - } - - if (selectedAccountCount <= 0) - { - await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "所选分类下没有可用执行账号")); - return; - } - - if (messageReferences.Count == 0) - { - await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "请至少填写一个消息引用")); - return; - } - - foreach (var reference in messageReferences) - { - if (!MessageReportTaskInputHelper.TryParseMessageReference(reference, out _, out var error)) - { - await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, $"消息引用无效:{reference}({error})")); - return; - } - } - - if (delayMinSeconds < 0 || delayMaxSeconds < 0) - { - await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "间隔不能为负数")); - return; - } - - if (delayMaxSeconds < delayMinSeconds) - { - await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "最大间隔不能小于最小间隔")); - return; - } - - if (maxReports < 0) - { - await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "最多举报次数不能小于 0")); - return; - } - - var cfg = new MessageReportTaskConfig - { - CategoryId = selectedCategories[0].Id, - CategoryName = selectedCategories[0].Name, - CategoryIds = selectedCategories.Select(x => x.Id).ToList(), - CategoryNames = selectedCategories.Select(x => x.Name).ToList(), - MessageReferences = messageReferences, - DelayMinMs = delayMinSeconds * 1000, - DelayMaxMs = delayMaxSeconds * 1000, - MaxReports = maxReports, - ReportType = MessageReportTaskReportTypes.Normalize(reportType), - OptionKeywords = optionKeywords, - Comments = comments, - CommentMode = MessageReportTaskModes.Normalize(commentMode), - CompletedAttempts = runtimeCompletedAttempts, - FailedAttempts = runtimeFailedAttempts, - AccountCursor = runtimeAccountCursor, - MessageCursor = runtimeMessageCursor, - CommentCursor = runtimeCommentCursor, - Canceled = false, - Error = null, - RecentFailures = runtimeRecentFailures.ToList() - }; - - var total = maxReports > 0 ? maxReports : 0; - var json = JsonSerializer.Serialize(cfg, new JsonSerializerOptions { WriteIndented = true }); - await DraftChanged.InvokeAsync(new ModuleTaskDraft(total, json, true, null)); - } - - private static int ConvertMillisecondsToSeconds(int milliseconds) - { - if (milliseconds <= 0) - return 0; - return (int)Math.Ceiling(milliseconds / 1000d); - } -} diff --git a/src/TelegramPanel.Modules.MessageReport/MessageReportTaskHandler.cs b/src/TelegramPanel.Modules.MessageReport/MessageReportTaskHandler.cs deleted file mode 100644 index 1263b7f..0000000 --- a/src/TelegramPanel.Modules.MessageReport/MessageReportTaskHandler.cs +++ /dev/null @@ -1,312 +0,0 @@ -using System.Text.Json; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using TelegramPanel.Core.Services; -using TelegramPanel.Core.Services.Telegram; -using TelegramPanel.Data.Entities; -using TelegramPanel.Modules; - -namespace TelegramPanel.Modules.MessageReport; - -public sealed class MessageReportTaskHandler : IModuleTaskHandler -{ - private const int MaxFailureLines = 20; - - public string TaskType => MessageReportTaskTypes.TaskType; - - public async Task ExecuteAsync(IModuleTaskExecutionHost host, CancellationToken cancellationToken) - { - var logger = host.Services.GetRequiredService>(); - var taskManagement = host.Services.GetRequiredService(); - var accountManagement = host.Services.GetRequiredService(); - var telegramService = host.Services.GetRequiredService(); - - var config = DeserializeConfig(host.Config); - ValidateAndNormalizeConfig(config); - - var selectedCategoryIds = MessageReportTaskInputHelper.NormalizeCategoryIds(config.CategoryIds, config.CategoryId).ToHashSet(); - var allAccounts = (await accountManagement.GetAllAccountsAsync()) - .Where(x => x.IsActive && x.UserId > 0 && x.Category?.ExcludeFromOperations != true) - .Where(x => x.CategoryId.HasValue && selectedCategoryIds.Contains(x.CategoryId.Value)) - .OrderBy(x => x.Id) - .ToList(); - - if (allAccounts.Count == 0) - { - config.Error = "所选分类下没有可用执行账号"; - await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(config)); - throw new InvalidOperationException(config.Error); - } - - var parsedMessageReferences = config.MessageReferences - .Select(raw => - { - if (!MessageReportTaskInputHelper.TryParseMessageReference(raw, out var parsed, out var error) || parsed == null) - throw new InvalidOperationException($"消息引用无效:{raw}({error})"); - return parsed; - }) - .ToList(); - - var completed = Math.Max(0, config.CompletedAttempts); - var failed = Math.Max(0, config.FailedAttempts); - var accountCursor = NormalizeCursor(config.AccountCursor, allAccounts.Count); - var messageCursor = NormalizeCursor(config.MessageCursor, parsedMessageReferences.Count); - var commentCursor = Math.Max(0, config.CommentCursor); - var resolvedCache = new Dictionary(StringComparer.OrdinalIgnoreCase); - - config.Canceled = false; - config.Error = null; - config.CompletedAttempts = completed; - config.FailedAttempts = failed; - config.AccountCursor = accountCursor; - config.MessageCursor = messageCursor; - config.CommentCursor = commentCursor; - await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(config)); - await host.UpdateProgressAsync(completed, failed, cancellationToken); - - while (!cancellationToken.IsCancellationRequested) - { - cancellationToken.ThrowIfCancellationRequested(); - - if (!await host.IsStillRunningAsync(cancellationToken)) - { - config.Canceled = true; - config.Error = null; - config.CompletedAttempts = completed; - config.FailedAttempts = failed; - config.AccountCursor = accountCursor; - config.MessageCursor = messageCursor; - config.CommentCursor = commentCursor; - await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(config)); - return; - } - - if (config.MaxReports > 0 && completed >= config.MaxReports) - break; - - var account = allAccounts[SelectIndex(allAccounts.Count, ref accountCursor)]; - var messageReference = parsedMessageReferences[SelectIndex(parsedMessageReferences.Count, ref messageCursor)]; - var cacheKey = $"{account.Id}:{messageReference.CanonicalId}"; - - string? failureReason = null; - try - { - if (!resolvedCache.TryGetValue(cacheKey, out var resolvedTarget)) - { - var resolved = await telegramService.ResolveMessageReferenceAsync(account.Id, messageReference.Raw, cancellationToken); - if (!resolved.Success || resolved.Target == null) - { - failureReason = NormalizeReason(resolved.Error); - } - else - { - resolvedTarget = resolved.Target; - resolvedCache[cacheKey] = resolvedTarget; - } - } - - if (failureReason == null && resolvedCache.TryGetValue(cacheKey, out var cachedTarget)) - { - var report = await telegramService.ReportMessageAsync(account.Id, cachedTarget, config, commentCursor, cancellationToken); - commentCursor = Math.Max(0, report.NextCommentCursor); - if (!string.IsNullOrWhiteSpace(report.SelectedOptionText)) - logger.LogDebug("Message report option selected: {OptionText}", report.SelectedOptionText); - if (!report.Success) - { - failureReason = NormalizeReason(report.Error); - if (LooksLikePeerInvalid(report.Error)) - resolvedCache.Remove(cacheKey); - } - } - } - catch (Exception ex) - { - logger.LogWarning(ex, "Message report task iteration failed (taskId={TaskId}, accountId={AccountId}, message={Message})", host.TaskId, account.Id, messageReference.CanonicalId); - var mapped = AccountTelegramToolsService.MapTelegramException(ex); - failureReason = NormalizeReason(string.IsNullOrWhiteSpace(mapped.details) ? mapped.summary : $"{mapped.summary}:{mapped.details}"); - } - - completed++; - if (!string.IsNullOrWhiteSpace(failureReason)) - { - failed++; - AddFailure(config, account, messageReference.Raw, failureReason!); - } - - config.Canceled = false; - config.Error = null; - config.CompletedAttempts = completed; - config.FailedAttempts = failed; - config.AccountCursor = accountCursor; - config.MessageCursor = messageCursor; - config.CommentCursor = commentCursor; - await host.UpdateProgressAsync(completed, failed, cancellationToken); - await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(config)); - - if (config.MaxReports > 0 && completed >= config.MaxReports) - break; - - if (!await DelayAsync(host, config.DelayMinMs, config.DelayMaxMs, cancellationToken)) - { - config.Canceled = true; - config.Error = null; - config.CompletedAttempts = completed; - config.FailedAttempts = failed; - config.AccountCursor = accountCursor; - config.MessageCursor = messageCursor; - config.CommentCursor = commentCursor; - await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(config)); - return; - } - } - - config.Canceled = false; - config.Error = null; - config.CompletedAttempts = completed; - config.FailedAttempts = failed; - config.AccountCursor = accountCursor; - config.MessageCursor = messageCursor; - config.CommentCursor = commentCursor; - await host.UpdateProgressAsync(completed, failed, cancellationToken); - await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(config)); - } - - private static MessageReportTaskConfig DeserializeConfig(string? rawConfig) - { - var raw = (rawConfig ?? string.Empty).Trim(); - if (raw.Length == 0) - throw new InvalidOperationException("任务配置为空"); - - try - { - return JsonSerializer.Deserialize(raw) - ?? throw new InvalidOperationException("任务配置解析结果为空"); - } - catch (Exception ex) - { - throw new InvalidOperationException($"任务配置 JSON 无效:{ex.Message}"); - } - } - - private static void ValidateAndNormalizeConfig(MessageReportTaskConfig config) - { - config.CategoryIds = MessageReportTaskInputHelper.NormalizeCategoryIds(config.CategoryIds, config.CategoryId); - config.CategoryNames = MessageReportTaskInputHelper.NormalizeCategoryNames(config.CategoryNames, config.CategoryName); - config.MessageReferences = MessageReportTaskInputHelper.NormalizeLines(config.MessageReferences, distinct: true); - config.OptionKeywords = MessageReportTaskInputHelper.NormalizeLines(config.OptionKeywords, distinct: true); - config.Comments = MessageReportTaskInputHelper.NormalizeLines(config.Comments); - config.CommentMode = MessageReportTaskModes.Normalize(config.CommentMode); - config.ReportType = MessageReportTaskReportTypes.Normalize(config.ReportType); - config.DelayMinMs = Math.Clamp(config.DelayMinMs, 0, 600000); - config.DelayMaxMs = Math.Clamp(config.DelayMaxMs, config.DelayMinMs, 600000); - config.MaxReports = Math.Max(0, config.MaxReports); - config.CompletedAttempts = Math.Max(0, config.CompletedAttempts); - config.FailedAttempts = Math.Max(0, config.FailedAttempts); - config.AccountCursor = Math.Max(0, config.AccountCursor); - config.MessageCursor = Math.Max(0, config.MessageCursor); - config.CommentCursor = Math.Max(0, config.CommentCursor); - config.RecentFailures ??= new List(); - - if (config.CategoryIds.Count == 0) - throw new InvalidOperationException("请至少选择一个执行账号分类"); - - if (config.MessageReferences.Count == 0) - throw new InvalidOperationException("请至少填写一个消息引用"); - - foreach (var messageReference in config.MessageReferences) - { - if (!MessageReportTaskInputHelper.TryParseMessageReference(messageReference, out _, out var error)) - throw new InvalidOperationException($"消息引用无效:{messageReference}({error})"); - } - } - - private static int SelectIndex(int count, ref int cursor) - { - if (count <= 0) - return 0; - - var index = NormalizeCursor(cursor, count); - cursor = (index + 1) % count; - return index; - } - - private static int NormalizeCursor(int cursor, int count) - { - if (count <= 0) - return 0; - if (cursor < 0) - return 0; - return cursor % count; - } - - private static async Task DelayAsync(IModuleTaskExecutionHost host, int minDelayMs, int maxDelayMs, CancellationToken cancellationToken) - { - var delayMs = NextDelayMilliseconds(minDelayMs, maxDelayMs); - if (delayMs <= 0) - return true; - - var remaining = delayMs; - while (remaining > 0) - { - cancellationToken.ThrowIfCancellationRequested(); - if (!await host.IsStillRunningAsync(cancellationToken)) - return false; - - var chunk = Math.Min(remaining, 1000); - await Task.Delay(chunk, cancellationToken); - remaining -= chunk; - } - - return true; - } - - private static int NextDelayMilliseconds(int minDelayMs, int maxDelayMs) - { - minDelayMs = Math.Max(0, minDelayMs); - maxDelayMs = Math.Max(minDelayMs, maxDelayMs); - return maxDelayMs <= minDelayMs - ? minDelayMs - : Random.Shared.Next(minDelayMs, maxDelayMs + 1); - } - - private static void AddFailure(MessageReportTaskConfig config, Account account, string messageReference, string reason) - { - config.RecentFailures ??= new List(); - config.RecentFailures.Add(new MessageReportTaskRuntimeFailure - { - TimestampUtc = DateTime.UtcNow, - AccountId = account.Id, - AccountDisplay = BuildAccountDisplayName(account), - MessageReference = messageReference, - Reason = reason - }); - - if (config.RecentFailures.Count > MaxFailureLines) - config.RecentFailures = config.RecentFailures.TakeLast(MaxFailureLines).ToList(); - } - - private static string BuildAccountDisplayName(Account account) - { - return $"#{account.Id} {(string.IsNullOrWhiteSpace(account.DisplayPhone) ? account.Phone : account.DisplayPhone)}"; - } - - private static bool LooksLikePeerInvalid(string? error) - { - var text = (error ?? string.Empty).ToUpperInvariant(); - return text.Contains("PEER_ID_INVALID", StringComparison.Ordinal) - || text.Contains("CHAT_ID_INVALID", StringComparison.Ordinal) - || text.Contains("CHANNEL_INVALID", StringComparison.Ordinal) - || text.Contains("USERNAME_INVALID", StringComparison.Ordinal) - || text.Contains("USERNAME_NOT_OCCUPIED", StringComparison.Ordinal); - } - - private static string NormalizeReason(string? error) - { - return string.IsNullOrWhiteSpace(error) ? "Telegram 请求失败" : error.Trim(); - } - - private static string SerializeIndented(MessageReportTaskConfig config) - { - return JsonSerializer.Serialize(config, new JsonSerializerOptions { WriteIndented = true }); - } -} diff --git a/src/TelegramPanel.Modules.MessageReport/MessageReportTaskModels.cs b/src/TelegramPanel.Modules.MessageReport/MessageReportTaskModels.cs deleted file mode 100644 index 4230f54..0000000 --- a/src/TelegramPanel.Modules.MessageReport/MessageReportTaskModels.cs +++ /dev/null @@ -1,266 +0,0 @@ -using System.Globalization; -using System.Text.Json.Serialization; - -namespace TelegramPanel.Modules.MessageReport; - -public static class MessageReportTaskTypes -{ - public const string TaskType = "module_message_report"; -} - -public static class MessageReportTaskModes -{ - public const string Random = "random"; - public const string Queue = "queue"; - - public static string Normalize(string? value) - { - return string.Equals((value ?? string.Empty).Trim(), Queue, StringComparison.OrdinalIgnoreCase) - ? Queue - : Random; - } -} - -public static class MessageReportTaskReportTypes -{ - public const string Auto = "auto"; - public const string Spam = "spam"; - public const string Violence = "violence"; - public const string Pornography = "pornography"; - public const string ChildAbuse = "child_abuse"; - public const string Copyright = "copyright"; - public const string PersonalDetails = "personal_details"; - public const string IllegalDrugs = "illegal_drugs"; - public const string Fake = "fake"; - public const string Other = "other"; - - public static string Normalize(string? value) - { - var normalized = (value ?? string.Empty).Trim().ToLowerInvariant(); - return normalized switch - { - Spam or Violence or Pornography or ChildAbuse or Copyright or PersonalDetails or IllegalDrugs or Fake or Other => normalized, - _ => Auto - }; - } -} - -public sealed class MessageReportTaskConfig -{ - [JsonPropertyName("category_id")] - public int CategoryId { get; set; } - - [JsonPropertyName("category_name")] - public string? CategoryName { get; set; } - - [JsonPropertyName("category_ids")] - public List CategoryIds { get; set; } = new(); - - [JsonPropertyName("category_names")] - public List CategoryNames { get; set; } = new(); - - [JsonPropertyName("message_references")] - public List MessageReferences { get; set; } = new(); - - [JsonPropertyName("delay_min_ms")] - public int DelayMinMs { get; set; } = 15000; - - [JsonPropertyName("delay_max_ms")] - public int DelayMaxMs { get; set; } = 45000; - - [JsonPropertyName("max_reports")] - public int MaxReports { get; set; } - - [JsonPropertyName("report_type")] - public string ReportType { get; set; } = MessageReportTaskReportTypes.Spam; - - [JsonPropertyName("option_keywords")] - public List OptionKeywords { get; set; } = new(); - - [JsonPropertyName("comments")] - public List Comments { get; set; } = new(); - - [JsonPropertyName("comment_mode")] - public string CommentMode { get; set; } = MessageReportTaskModes.Queue; - - [JsonPropertyName("completed_attempts")] - public int CompletedAttempts { get; set; } - - [JsonPropertyName("failed_attempts")] - public int FailedAttempts { get; set; } - - [JsonPropertyName("account_cursor")] - public int AccountCursor { get; set; } - - [JsonPropertyName("message_cursor")] - public int MessageCursor { get; set; } - - [JsonPropertyName("comment_cursor")] - public int CommentCursor { get; set; } - - [JsonPropertyName("canceled")] - public bool Canceled { get; set; } - - [JsonPropertyName("error")] - public string? Error { get; set; } - - [JsonPropertyName("recent_failures")] - public List RecentFailures { get; set; } = new(); -} - -public sealed class MessageReportTaskRuntimeFailure -{ - [JsonPropertyName("timestamp_utc")] - public DateTime TimestampUtc { get; set; } = DateTime.UtcNow; - - [JsonPropertyName("account_id")] - public int AccountId { get; set; } - - [JsonPropertyName("account_display")] - public string AccountDisplay { get; set; } = string.Empty; - - [JsonPropertyName("message_reference")] - public string MessageReference { get; set; } = string.Empty; - - [JsonPropertyName("reason")] - public string Reason { get; set; } = string.Empty; -} - -public sealed record ParsedMessageReference(string Raw, string Target, int MessageId, string CanonicalId); - -public static class MessageReportTaskInputHelper -{ - public static List NormalizeCategoryIds(IEnumerable? values, int fallback) - { - var ids = (values ?? Array.Empty()) - .Where(x => x > 0) - .Distinct() - .ToList(); - - if (ids.Count == 0 && fallback > 0) - ids.Add(fallback); - - return ids; - } - - public static List NormalizeCategoryNames(IEnumerable? values, string? fallback) - { - var names = NormalizeLines(values, distinct: true); - var fallbackName = (fallback ?? string.Empty).Trim(); - if (names.Count == 0 && fallbackName.Length > 0) - names.Add(fallbackName); - return names; - } - - public static List NormalizeLines(IEnumerable? values, bool distinct = false) - { - var lines = (values ?? Array.Empty()) - .Select(x => (x ?? string.Empty).Trim()) - .Where(x => x.Length > 0) - .ToList(); - - return distinct - ? lines.Distinct(StringComparer.OrdinalIgnoreCase).ToList() - : lines; - } - - public static List ParseMultilineText(string? text, bool distinct = false) - { - if (string.IsNullOrWhiteSpace(text)) - return new List(); - - return NormalizeLines( - text.Replace("\r", string.Empty).Split('\n', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries), - distinct); - } - - public static bool TryParseMessageReference(string? raw, out ParsedMessageReference? parsed, out string? error) - { - parsed = null; - error = null; - - var value = (raw ?? string.Empty).Trim(); - if (value.Length == 0) - { - error = "消息引用不能为空"; - return false; - } - - value = NormalizeTelegramPath(value); - if (value.Length == 0) - { - error = "消息引用格式无效"; - return false; - } - - var parts = value.Split('/', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); - string target; - string messageIdText; - - if (parts.Length >= 3 && string.Equals(parts[0], "c", StringComparison.OrdinalIgnoreCase)) - { - if (!long.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var internalChatId) || internalChatId <= 0) - { - error = "t.me/c 链接中的频道 ID 无效"; - return false; - } - - target = $"-100{internalChatId}"; - messageIdText = parts[2]; - } - else if (parts.Length >= 2) - { - target = parts[0]; - messageIdText = parts[1]; - } - else - { - error = "消息引用格式无效,应类似 username/123 或 t.me/c/1234567890/123"; - return false; - } - - target = target.Trim().TrimStart('@'); - if (target.Length == 0) - { - error = "消息引用缺少目标"; - return false; - } - - if (!int.TryParse(messageIdText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var messageId) || messageId <= 0) - { - error = "消息 ID 无效,必须是正整数"; - return false; - } - - parsed = new ParsedMessageReference((raw ?? string.Empty).Trim(), target, messageId, $"{target}/{messageId}"); - return true; - } - - private static string NormalizeTelegramPath(string value) - { - var raw = value.Trim(); - if (Uri.TryCreate(raw, UriKind.Absolute, out var absolute)) - { - var host = (absolute.Host ?? string.Empty).Trim().ToLowerInvariant(); - if (host is not ("t.me" or "www.t.me" or "telegram.me" or "www.telegram.me")) - return string.Empty; - - raw = absolute.PathAndQuery; - } - else if (raw.StartsWith("t.me/", StringComparison.OrdinalIgnoreCase) - || raw.StartsWith("telegram.me/", StringComparison.OrdinalIgnoreCase)) - { - raw = raw[(raw.IndexOf('/') + 1)..]; - } - - var questionMarkIndex = raw.IndexOf('?'); - if (questionMarkIndex >= 0) - raw = raw[..questionMarkIndex]; - - raw = raw.Trim().Trim('/').Replace('\\', '/'); - if (raw.StartsWith("s/", StringComparison.OrdinalIgnoreCase)) - raw = raw[2..]; - - return raw.Trim('/'); - } -} diff --git a/src/TelegramPanel.Modules.MessageReport/MessageReportTaskModule.cs b/src/TelegramPanel.Modules.MessageReport/MessageReportTaskModule.cs deleted file mode 100644 index 3195629..0000000 --- a/src/TelegramPanel.Modules.MessageReport/MessageReportTaskModule.cs +++ /dev/null @@ -1,56 +0,0 @@ -using Microsoft.AspNetCore.Routing; -using Microsoft.Extensions.DependencyInjection; -using MudBlazor; -using TelegramPanel.Modules; - -namespace TelegramPanel.Modules.MessageReport; - -public sealed class MessageReportTaskModule : ITelegramPanelModule, IModuleTaskProvider -{ - public ModuleManifest Manifest { get; } = new() - { - Id = "task.message-report", - Name = "任务:消息举报", - Version = "1.0.0", - Host = new HostCompatibility { Min = "0.0.0", Max = "2.0.0" }, - Entry = new ModuleEntryPoint - { - Assembly = "TelegramPanel.Modules.MessageReport.dll", - Type = typeof(MessageReportTaskModule).FullName ?? string.Empty - } - }; - - public void ConfigureServices(IServiceCollection services, ModuleHostContext context) - { - services.AddScoped(); - services.AddScoped(); - services.AddSingleton(); - } - - public void MapEndpoints(IEndpointRouteBuilder endpoints, ModuleHostContext context) - { - } - - public IEnumerable GetTasks(ModuleHostContext context) - { - yield return new ModuleTaskDefinition - { - Category = "user", - TaskType = MessageReportTaskTypes.TaskType, - DisplayName = "消息举报(多账号)", - Description = "按账号分类持续举报指定消息,支持间隔控制、达到次数后停止,也支持 Cron 计划触发。", - Icon = Icons.Material.Filled.Warning, - EditorComponentType = typeof(MessageReportTaskEditor).AssemblyQualifiedName ?? string.Empty, - TaskCenter = new ModuleTaskCenterCapabilities - { - CanPause = true, - CanResume = true, - CanEdit = true, - CanRerun = true, - EditComponentType = typeof(MessageReportTaskEditor).AssemblyQualifiedName ?? string.Empty, - AutoPauseBeforeEdit = true - }, - Order = 160 - }; - } -} diff --git a/src/TelegramPanel.Modules.MessageReport/MessageReportTaskRerunBuilder.cs b/src/TelegramPanel.Modules.MessageReport/MessageReportTaskRerunBuilder.cs deleted file mode 100644 index 6a47ad2..0000000 --- a/src/TelegramPanel.Modules.MessageReport/MessageReportTaskRerunBuilder.cs +++ /dev/null @@ -1,75 +0,0 @@ -using System.Text.Json; -using TelegramPanel.Modules; - -namespace TelegramPanel.Modules.MessageReport; - -public sealed class MessageReportTaskRerunBuilder : IModuleTaskRerunBuilder -{ - public string TaskType => MessageReportTaskTypes.TaskType; - - public ModuleTaskCreateRequest Build(ModuleTaskSnapshot task) - { - var rerunConfig = BuildRerunConfig(task.Config); - var total = rerunConfig.MaxReports > 0 ? rerunConfig.MaxReports : 0; - - return new ModuleTaskCreateRequest - { - TaskType = MessageReportTaskTypes.TaskType, - Total = total, - Config = JsonSerializer.Serialize(rerunConfig, new JsonSerializerOptions { WriteIndented = true }) - }; - } - - private static MessageReportTaskConfig BuildRerunConfig(string? rawConfig) - { - var raw = (rawConfig ?? string.Empty).Trim(); - if (raw.Length == 0) - throw new InvalidOperationException("任务配置为空,无法重新运行"); - - MessageReportTaskConfig cfg; - try - { - cfg = JsonSerializer.Deserialize(raw) - ?? throw new InvalidOperationException("任务配置解析结果为空"); - } - catch (Exception ex) - { - throw new InvalidOperationException($"任务配置 JSON 无效:{ex.Message}"); - } - - cfg.CategoryIds = MessageReportTaskInputHelper.NormalizeCategoryIds(cfg.CategoryIds, cfg.CategoryId); - if (cfg.CategoryIds.Count == 0) - throw new InvalidOperationException("任务缺少账号分类,无法重新运行"); - - cfg.CategoryNames = MessageReportTaskInputHelper.NormalizeCategoryNames(cfg.CategoryNames, cfg.CategoryName); - cfg.CategoryId = cfg.CategoryIds[0]; - cfg.CategoryName = cfg.CategoryNames.FirstOrDefault() ?? cfg.CategoryName; - cfg.MessageReferences = MessageReportTaskInputHelper.NormalizeLines(cfg.MessageReferences, distinct: true); - cfg.OptionKeywords = MessageReportTaskInputHelper.NormalizeLines(cfg.OptionKeywords, distinct: true); - cfg.Comments = MessageReportTaskInputHelper.NormalizeLines(cfg.Comments); - cfg.ReportType = MessageReportTaskReportTypes.Normalize(cfg.ReportType); - cfg.CommentMode = MessageReportTaskModes.Normalize(cfg.CommentMode); - - foreach (var messageReference in cfg.MessageReferences) - { - if (!MessageReportTaskInputHelper.TryParseMessageReference(messageReference, out _, out var error)) - throw new InvalidOperationException($"消息引用无效:{messageReference}({error})"); - } - - if (cfg.MessageReferences.Count == 0) - throw new InvalidOperationException("任务缺少消息引用,无法重新运行"); - - cfg.DelayMinMs = Math.Clamp(cfg.DelayMinMs, 0, 600000); - cfg.DelayMaxMs = Math.Clamp(cfg.DelayMaxMs, cfg.DelayMinMs, 600000); - cfg.MaxReports = Math.Max(0, cfg.MaxReports); - cfg.CompletedAttempts = 0; - cfg.FailedAttempts = 0; - cfg.AccountCursor = 0; - cfg.MessageCursor = 0; - cfg.CommentCursor = 0; - cfg.Canceled = false; - cfg.Error = null; - cfg.RecentFailures = new List(); - return cfg; - } -} diff --git a/src/TelegramPanel.Modules.MessageReport/MessageReportTaskTelegramService.cs b/src/TelegramPanel.Modules.MessageReport/MessageReportTaskTelegramService.cs deleted file mode 100644 index 0739c3dd..0000000 --- a/src/TelegramPanel.Modules.MessageReport/MessageReportTaskTelegramService.cs +++ /dev/null @@ -1,419 +0,0 @@ -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Logging; -using TelegramPanel.Core.Interfaces; -using TelegramPanel.Core.Services; -using TelegramPanel.Core.Services.Telegram; -using TelegramPanel.Data.Entities; -using TL; -using WTelegram; - -namespace TelegramPanel.Modules.MessageReport; - -public sealed class MessageReportTaskTelegramService -{ - private static readonly IReadOnlyDictionary ReportTypeKeywords = new Dictionary(StringComparer.OrdinalIgnoreCase) - { - [MessageReportTaskReportTypes.Spam] = ["spam", "垃圾", "广告"], - [MessageReportTaskReportTypes.Violence] = ["violence", "violent", "暴力"], - [MessageReportTaskReportTypes.Pornography] = ["porn", "pornography", "sexual", "nsfw", "色情", "成人"], - [MessageReportTaskReportTypes.ChildAbuse] = ["child", "minor", "underage", "儿童", "未成年", "虐待", "剥削"], - [MessageReportTaskReportTypes.Copyright] = ["copyright", "版权", "侵权", "盗版"], - [MessageReportTaskReportTypes.PersonalDetails] = ["personal", "private", "privacy", "隐私", "个人信息", "泄露"], - [MessageReportTaskReportTypes.IllegalDrugs] = ["drug", "drugs", "毒品", "违禁药"], - [MessageReportTaskReportTypes.Fake] = ["fake", "fraud", "scam", "冒充", "诈骗", "虚假"], - [MessageReportTaskReportTypes.Other] = ["other", "其他"] - }; - - private readonly AccountManagementService _accountManagement; - private readonly ITelegramClientPool _clientPool; - private readonly AccountTelegramToolsService _accountTools; - private readonly IConfiguration _configuration; - private readonly ILogger _logger; - - public MessageReportTaskTelegramService( - AccountManagementService accountManagement, - ITelegramClientPool clientPool, - AccountTelegramToolsService accountTools, - IConfiguration configuration, - ILogger logger) - { - _accountManagement = accountManagement; - _clientPool = clientPool; - _accountTools = accountTools; - _configuration = configuration; - _logger = logger; - } - - public async Task<(bool Success, string? Error, ResolvedMessageTarget? Target)> ResolveMessageReferenceAsync( - int accountId, - string rawReference, - CancellationToken cancellationToken) - { - if (!MessageReportTaskInputHelper.TryParseMessageReference(rawReference, out var parsed, out var parseError) || parsed == null) - return (false, parseError ?? "消息引用无效", null); - - var resolved = await _accountTools.ResolveChatTargetAsync(accountId, parsed.Target, cancellationToken); - if (!resolved.Success || resolved.Target == null) - return (false, NormalizeError(resolved.Error), null); - - return (true, null, new ResolvedMessageTarget(parsed, resolved.Target)); - } - - public async Task ReportMessageAsync( - int accountId, - ResolvedMessageTarget target, - MessageReportTaskConfig config, - int commentCursor, - CancellationToken cancellationToken) - { - try - { - var client = await GetOrCreateConnectedClientAsync(accountId, cancellationToken); - var option = Array.Empty(); - var comment = string.Empty; - string? selectedOptionText = null; - var nextCommentCursor = commentCursor; - - for (var step = 0; step < 6; step++) - { - cancellationToken.ThrowIfCancellationRequested(); - - var result = await ExecuteTelegramRequestAsync( - accountId, - "举报消息", - () => client.Messages_Report(target.ChatTarget.Peer, [target.Reference.MessageId], option, comment), - cancellationToken, - resetClientOnTimeout: true); - - switch (result) - { - case ReportResultReported: - return ReportMessageResult.Ok(nextCommentCursor, selectedOptionText); - - case ReportResultChooseOption chooseOption: - { - var selected = SelectOption(chooseOption.options, config.ReportType, config.OptionKeywords, out var matchError); - if (selected == null) - return ReportMessageResult.Fail(matchError ?? "无法匹配 Telegram 返回的举报菜单", nextCommentCursor); - - option = selected.option ?? Array.Empty(); - comment = string.Empty; - selectedOptionText = selected.text; - break; - } - - case ReportResultAddComment addComment: - { - option = addComment.option ?? option; - var selectedComment = PickComment(config.Comments, config.CommentMode, nextCommentCursor, out nextCommentCursor); - if (string.IsNullOrWhiteSpace(selectedComment)) - { - if (!addComment.flags.HasFlag(ReportResultAddComment.Flags.optional)) - return ReportMessageResult.Fail("当前举报步骤要求填写举报说明,但任务配置未提供可用文案", nextCommentCursor); - - comment = string.Empty; - } - else - { - comment = selectedComment; - } - - break; - } - - default: - return ReportMessageResult.Fail($"收到未知举报结果类型:{result.GetType().Name}", nextCommentCursor); - } - } - - return ReportMessageResult.Fail("举报流程超过允许的最大步骤数", commentCursor); - } - catch (Exception ex) - { - var mapped = AccountTelegramToolsService.MapTelegramException(ex); - _logger.LogWarning(ex, "Message report failed for account {AccountId}, target {Target}", accountId, target.Reference.CanonicalId); - return ReportMessageResult.Fail(string.IsNullOrWhiteSpace(mapped.details) ? mapped.summary : $"{mapped.summary}:{mapped.details}", commentCursor); - } - } - - private async Task GetOrCreateConnectedClientAsync(int accountId, CancellationToken cancellationToken) - { - cancellationToken.ThrowIfCancellationRequested(); - - var existing = _clientPool.GetClient(accountId); - if (existing?.User != null) - return existing; - - var account = await _accountManagement.GetAccountAsync(accountId) - ?? throw new InvalidOperationException($"账号不存在:{accountId}"); - - var apiId = ResolveApiId(account); - var apiHash = ResolveApiHash(account); - var sessionKey = ResolveSessionKey(account, apiHash); - - if (string.IsNullOrWhiteSpace(account.SessionPath)) - throw new InvalidOperationException("账号缺少 SessionPath,无法创建 Telegram 客户端"); - - var absoluteSessionPath = Path.GetFullPath(account.SessionPath); - if (File.Exists(absoluteSessionPath) && SessionDataConverter.LooksLikeSqliteSession(absoluteSessionPath)) - { - var converted = await SessionDataConverter.TryConvertSqliteSessionFromJsonAsync( - phone: account.Phone, - apiId: account.ApiId, - apiHash: account.ApiHash, - sqliteSessionPath: absoluteSessionPath, - logger: _logger); - - if (!converted.Ok) - { - throw new InvalidOperationException( - $"该账号的 Session 文件为 SQLite 格式:{account.SessionPath},无法自动转换为可用 session。原因:{converted.Reason}。" - + "建议:重新导入包含 session_string 的 json,或到【账号-手机号登录】重新登录生成新的 sessions/*.session。" - ); - } - } - - await _clientPool.RemoveClientAsync(accountId); - - var client = await _clientPool.GetOrCreateClientAsync( - accountId: accountId, - apiId: apiId, - apiHash: apiHash, - sessionPath: account.SessionPath, - sessionKey: sessionKey, - phoneNumber: account.Phone, - userId: account.UserId > 0 ? account.UserId : null); - - try - { - await ExecuteTelegramRequestAsync( - accountId, - "连接 Telegram", - () => client.ConnectAsync(), - cancellationToken, - resetClientOnTimeout: true); - - if (client.User == null && (client.UserId != 0 || account.UserId != 0)) - { - await ExecuteTelegramRequestAsync( - accountId, - "恢复 Telegram 登录状态", - () => client.LoginUserIfNeeded(reloginOnFailedResume: false), - cancellationToken, - resetClientOnTimeout: true); - } - } - catch (Exception ex) - { - if (LooksLikeSessionApiMismatchOrCorrupted(ex)) - { - throw new InvalidOperationException( - "该账号的 Session 文件无法解析(通常是 ApiId/ApiHash 与生成 session 时不一致,或 session 文件已损坏)。" - + "请到【账号-手机号登录】重新登录生成新的 session 后再试。", - ex); - } - - throw new InvalidOperationException($"Telegram 会话加载失败:{ex.Message}", ex); - } - - if (client.User == null) - throw new InvalidOperationException("账号未登录或 session 已失效,请重新登录生成新的 session"); - - return client; - } - - private async Task ExecuteTelegramRequestAsync( - int accountId, - string operation, - Func action, - CancellationToken cancellationToken, - bool resetClientOnTimeout = false) - { - try - { - await action().WaitAsync(GetTelegramRequestTimeout(), cancellationToken); - } - catch (TimeoutException) - { - if (resetClientOnTimeout) - await _clientPool.RemoveClientAsync(accountId); - - throw new TimeoutException($"{operation} 超时"); - } - } - - private async Task ExecuteTelegramRequestAsync( - int accountId, - string operation, - Func> action, - CancellationToken cancellationToken, - bool resetClientOnTimeout = false) - { - try - { - return await action().WaitAsync(GetTelegramRequestTimeout(), cancellationToken); - } - catch (TimeoutException) - { - if (resetClientOnTimeout) - await _clientPool.RemoveClientAsync(accountId); - - throw new TimeoutException($"{operation} 超时"); - } - } - - private TimeSpan GetTelegramRequestTimeout() - { - if (int.TryParse(_configuration["Telegram:RequestTimeoutSeconds"], out var seconds) && seconds >= 10 && seconds <= 600) - return TimeSpan.FromSeconds(seconds); - - return TimeSpan.FromSeconds(45); - } - - private static MessageReportOption? SelectOption( - IEnumerable? options, - string reportType, - IEnumerable? customKeywords, - out string? error) - { - error = null; - var optionList = (options ?? Array.Empty()).ToList(); - if (optionList.Count == 0) - { - error = "Telegram 未返回可用的举报选项"; - return null; - } - - var keywords = BuildKeywords(reportType, customKeywords).ToList(); - if (keywords.Count > 0) - { - foreach (var candidate in optionList) - { - var normalizedText = NormalizeForMatch(candidate.text); - if (keywords.Any(keyword => normalizedText.Contains(keyword, StringComparison.Ordinal))) - return candidate; - } - } - - if (string.Equals(reportType, MessageReportTaskReportTypes.Auto, StringComparison.OrdinalIgnoreCase)) - return optionList[0]; - - if (optionList.Count == 1) - return optionList[0]; - - error = "未能根据当前配置匹配 Telegram 返回的举报选项:" - + string.Join(" / ", optionList.Select(x => string.IsNullOrWhiteSpace(x.text) ? "<空标题>" : x.text.Trim())); - return null; - } - - private static IEnumerable BuildKeywords(string reportType, IEnumerable? customKeywords) - { - var normalizedReportType = MessageReportTaskReportTypes.Normalize(reportType); - if (ReportTypeKeywords.TryGetValue(normalizedReportType, out var defaultKeywords)) - { - foreach (var keyword in defaultKeywords) - { - var normalized = NormalizeForMatch(keyword); - if (normalized.Length > 0) - yield return normalized; - } - } - - foreach (var keyword in MessageReportTaskInputHelper.NormalizeLines(customKeywords, distinct: true)) - { - var normalized = NormalizeForMatch(keyword); - if (normalized.Length > 0) - yield return normalized; - } - } - - private static string NormalizeForMatch(string? value) - { - return new string((value ?? string.Empty) - .Trim() - .ToLowerInvariant() - .Where(ch => !char.IsWhiteSpace(ch) && ch is not '-' and not '_' and not ':' and not ':') - .ToArray()); - } - - private static string? PickComment(IReadOnlyList? comments, string mode, int cursor, out int nextCursor) - { - var normalized = MessageReportTaskInputHelper.NormalizeLines(comments, distinct: false); - if (normalized.Count == 0) - { - nextCursor = cursor; - return null; - } - - if (string.Equals(MessageReportTaskModes.Normalize(mode), MessageReportTaskModes.Queue, StringComparison.OrdinalIgnoreCase)) - { - var index = NormalizeCursor(cursor, normalized.Count); - nextCursor = normalized.Count == 0 ? 0 : (index + 1) % normalized.Count; - return normalized[index]; - } - - nextCursor = cursor; - return normalized[Random.Shared.Next(normalized.Count)]; - } - - private static int NormalizeCursor(int cursor, int count) - { - if (count <= 0) - return 0; - - if (cursor < 0) - return 0; - - return cursor % count; - } - - private static string NormalizeError(string? error) - { - return string.IsNullOrWhiteSpace(error) ? "Telegram 请求失败" : error.Trim(); - } - - private int ResolveApiId(Account account) - { - if (int.TryParse(_configuration["Telegram:ApiId"], out var globalApiId) && globalApiId > 0) - return globalApiId; - if (account.ApiId > 0) - return account.ApiId; - throw new InvalidOperationException("未配置全局 ApiId,且账号缺少 ApiId"); - } - - private string ResolveApiHash(Account account) - { - var global = _configuration["Telegram:ApiHash"]; - if (!string.IsNullOrWhiteSpace(global)) - return global.Trim(); - if (!string.IsNullOrWhiteSpace(account.ApiHash)) - return account.ApiHash.Trim(); - throw new InvalidOperationException("未配置全局 ApiHash,且账号缺少 ApiHash"); - } - - private static string ResolveSessionKey(Account account, string apiHash) - { - return !string.IsNullOrWhiteSpace(account.ApiHash) ? account.ApiHash.Trim() : apiHash.Trim(); - } - - private static bool LooksLikeSessionApiMismatchOrCorrupted(Exception ex) - { - var message = ex.Message ?? string.Empty; - return message.Contains("Can't read session block", StringComparison.OrdinalIgnoreCase) - || message.Contains("Use the correct api_hash", StringComparison.OrdinalIgnoreCase) - || message.Contains("Use the correct api_hash/id/key", StringComparison.OrdinalIgnoreCase); - } -} - -public sealed record ResolvedMessageTarget( - ParsedMessageReference Reference, - AccountTelegramToolsService.ResolvedChatTarget ChatTarget); - -public sealed record ReportMessageResult(bool Success, string? Error, int NextCommentCursor, string? SelectedOptionText) -{ - public static ReportMessageResult Ok(int nextCommentCursor, string? selectedOptionText) - => new(true, null, nextCommentCursor, selectedOptionText); - - public static ReportMessageResult Fail(string error, int nextCommentCursor) - => new(false, error, nextCommentCursor, null); -} diff --git a/src/TelegramPanel.Modules.MessageReport/TelegramPanel.Modules.MessageReport.csproj b/src/TelegramPanel.Modules.MessageReport/TelegramPanel.Modules.MessageReport.csproj deleted file mode 100644 index f1c4730..0000000 --- a/src/TelegramPanel.Modules.MessageReport/TelegramPanel.Modules.MessageReport.csproj +++ /dev/null @@ -1,23 +0,0 @@ - - - net8.0 - enable - enable - TelegramPanel.Modules.MessageReport - TelegramPanel.Modules.MessageReport - - - - - - - - - - - - - - - - diff --git a/src/TelegramPanel.Modules.MessageReport/_Imports.razor b/src/TelegramPanel.Modules.MessageReport/_Imports.razor deleted file mode 100644 index 51a4aef..0000000 --- a/src/TelegramPanel.Modules.MessageReport/_Imports.razor +++ /dev/null @@ -1,4 +0,0 @@ -@using Microsoft.AspNetCore.Components -@using MudBlazor -@using TelegramPanel.Modules -@using TelegramPanel.Modules.MessageReport diff --git a/src/TelegramPanel.Modules.MessageReport/manifest.json b/src/TelegramPanel.Modules.MessageReport/manifest.json deleted file mode 100644 index 5fb95d4..0000000 --- a/src/TelegramPanel.Modules.MessageReport/manifest.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "id": "task.message-report", - "name": "任务:消息举报", - "version": "1.0.0", - "host": { "min": "0.0.0", "max": "2.0.0" }, - "dependencies": [], - "entry": { - "assembly": "TelegramPanel.Modules.MessageReport.dll", - "type": "TelegramPanel.Modules.MessageReport.MessageReportTaskModule" - } -} diff --git a/src/TelegramPanel.Modules.MessageReportTask/Components/Dialogs/MessageReportTaskEditor.razor b/src/TelegramPanel.Modules.MessageReportTask/Components/Dialogs/MessageReportTaskEditor.razor deleted file mode 100644 index 9a2c644..0000000 --- a/src/TelegramPanel.Modules.MessageReportTask/Components/Dialogs/MessageReportTaskEditor.razor +++ /dev/null @@ -1,315 +0,0 @@ -@using System.Text.Json -@using TelegramPanel.Modules.MessageReportTask.Services -@inject AccountCategoryManagementService CategoryManagement - - - - 该任务会按账号分类轮流对指定消息链接执行举报。最多举报次数为 0 表示不设上限,直到所有可用账号-消息组合处理完成或你在任务中心手动停止。 - - - - Telegram 的消息举报是动态选项流程。这里的“举报类型”本质上是匹配 Telegram 返回的选项文字;如果匹配不到,任务会记失败,但不会盲选错误类型。 - - - @if (loading) - { - - 正在加载账号分类... - } - else - { - - @foreach (var c in categories) - { - @c.Name - } - - - - - - - - - - - - - - - - - - 垃圾 / 骚扰 - 暴力 / 威胁 - 色情 / 淫秽 - 儿童虐待 - 版权侵权 - 违禁药物 - 隐私 / 个人信息 - 其他 - 直接选第一个选项 - 自定义关键词 - - - - - - } - - -@code { - [Parameter] public ModuleTaskDraft Draft { get; set; } - [Parameter] public EventCallback DraftChanged { get; set; } - [Parameter] public string? InitialConfigJson { get; set; } - - private List categories = new(); - private bool loading = true; - private bool initializedFromConfig; - - private IEnumerable selectedCategoryIds = Array.Empty(); - private string messageLinksText = string.Empty; - private int delayMinSeconds = 15; - private int delayMaxSeconds = 45; - private int maxReports; - private string reportPreset = MessageReportTaskPresets.Spam; - private string customKeywordsText = string.Empty; - private string commentText = string.Empty; - - protected override async Task OnInitializedAsync() - { - ApplyInitialConfigIfNeeded(); - - try - { - categories = (await CategoryManagement.GetAllCategoriesAsync()) - .Where(x => !x.ExcludeFromOperations) - .OrderBy(x => x.Name, StringComparer.OrdinalIgnoreCase) - .ToList(); - - if (!selectedCategoryIds.Any()) - { - var firstCategoryId = categories.FirstOrDefault()?.Id ?? 0; - selectedCategoryIds = firstCategoryId > 0 ? new[] { firstCategoryId } : Array.Empty(); - } - } - finally - { - loading = false; - await PushDraftAsync(); - } - } - - private void ApplyInitialConfigIfNeeded() - { - if (initializedFromConfig) - return; - - initializedFromConfig = true; - - var raw = (InitialConfigJson ?? string.Empty).Trim(); - if (raw.Length == 0) - return; - - try - { - var cfg = JsonSerializer.Deserialize(raw); - if (cfg == null) - return; - - var categoryIds = NormalizeCategoryIds(cfg.CategoryIds, cfg.CategoryId); - if (categoryIds.Count > 0) - selectedCategoryIds = categoryIds; - - messageLinksText = string.Join(Environment.NewLine, cfg.MessageLinks ?? new List()); - delayMinSeconds = ConvertMillisecondsToSeconds(cfg.DelayMinMs); - delayMaxSeconds = ConvertMillisecondsToSeconds(cfg.DelayMaxMs); - maxReports = Math.Max(0, cfg.MaxReports); - reportPreset = NormalizePreset(cfg.ReportPreset); - customKeywordsText = string.Join(Environment.NewLine, cfg.OptionKeywords ?? new List()); - commentText = (cfg.Comment ?? string.Empty).Trim(); - } - catch - { - } - } - - private async Task OnSelectedCategoriesChanged(IEnumerable values) - { - selectedCategoryIds = NormalizeCategoryIds(values, 0); - await OnInputsChanged(); - } - - private async Task OnInputsChanged() - { - await PushDraftAsync(); - } - - private async Task PushDraftAsync() - { - var links = ParseLines(messageLinksText, distinct: true); - var selectedIds = NormalizeCategoryIds(selectedCategoryIds, 0); - var selectedCategories = categories.Where(x => selectedIds.Contains(x.Id)).ToList(); - var keywords = ParseLines(customKeywordsText, distinct: true); - var normalizedPreset = NormalizePreset(reportPreset); - var comment = NormalizeNullableText(commentText); - - if (loading) - { - await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "账号分类加载中")); - return; - } - - if (categories.Count == 0) - { - await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "没有可用账号分类(或都被标记为排除操作)")); - return; - } - - if (selectedCategories.Count == 0) - { - await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "请至少选择一个执行账号分类")); - return; - } - - if (links.Count == 0) - { - await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "请至少填写一条消息链接")); - return; - } - - if (delayMinSeconds < 0 || delayMaxSeconds < 0) - { - await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "间隔不能为负数")); - return; - } - - if (delayMaxSeconds < delayMinSeconds) - { - await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "最大间隔不能小于最小间隔")); - return; - } - - if (maxReports < 0) - { - await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "最多举报次数不能小于 0")); - return; - } - - if (normalizedPreset == MessageReportTaskPresets.Custom && keywords.Count == 0) - { - await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "选择“自定义关键词”时,至少填写一个关键词")); - return; - } - - var categoryNames = selectedCategories.Select(x => x.Name).ToList(); - var cfg = new MessageReportTaskConfig - { - CategoryId = selectedCategories[0].Id, - CategoryName = selectedCategories[0].Name, - CategoryIds = selectedCategories.Select(x => x.Id).ToList(), - CategoryNames = categoryNames, - MessageLinks = links, - DelayMinMs = delayMinSeconds * 1000, - DelayMaxMs = delayMaxSeconds * 1000, - MaxReports = maxReports, - ReportPreset = normalizedPreset, - OptionKeywords = keywords, - Comment = comment - }; - - var total = maxReports > 0 ? maxReports : 0; - var json = JsonSerializer.Serialize(cfg, new JsonSerializerOptions { WriteIndented = true }); - await DraftChanged.InvokeAsync(new ModuleTaskDraft(total, json, true, null)); - } - - private static List NormalizeCategoryIds(IEnumerable? values, int fallback) - { - var ids = (values ?? Array.Empty()) - .Where(x => x > 0) - .Distinct() - .ToList(); - - if (ids.Count == 0 && fallback > 0) - ids.Add(fallback); - - return ids; - } - - private static int ConvertMillisecondsToSeconds(int value) - { - if (value <= 0) - return 0; - - return Math.Max(1, (int)Math.Ceiling(value / 1000d)); - } - - private static string NormalizePreset(string? value) - { - var preset = (value ?? string.Empty).Trim().ToLowerInvariant(); - return preset switch - { - MessageReportTaskPresets.Spam => MessageReportTaskPresets.Spam, - MessageReportTaskPresets.Violence => MessageReportTaskPresets.Violence, - MessageReportTaskPresets.Pornography => MessageReportTaskPresets.Pornography, - MessageReportTaskPresets.ChildAbuse => MessageReportTaskPresets.ChildAbuse, - MessageReportTaskPresets.Copyright => MessageReportTaskPresets.Copyright, - MessageReportTaskPresets.IllegalDrugs => MessageReportTaskPresets.IllegalDrugs, - MessageReportTaskPresets.PersonalDetails => MessageReportTaskPresets.PersonalDetails, - MessageReportTaskPresets.Other => MessageReportTaskPresets.Other, - MessageReportTaskPresets.FirstAvailable => MessageReportTaskPresets.FirstAvailable, - MessageReportTaskPresets.Custom => MessageReportTaskPresets.Custom, - _ => MessageReportTaskPresets.Spam - }; - } - - private static List ParseLines(string? text, bool distinct) - { - if (string.IsNullOrWhiteSpace(text)) - return new List(); - - var values = text - .Split(new[] { "\r\n", "\n", "\r" }, StringSplitOptions.RemoveEmptyEntries) - .Select(x => (x ?? string.Empty).Trim()) - .Where(x => x.Length > 0); - - return distinct - ? values.Distinct(StringComparer.OrdinalIgnoreCase).ToList() - : values.ToList(); - } - - private static string? NormalizeNullableText(string? value) - { - var text = (value ?? string.Empty).Trim(); - return text.Length == 0 ? null : text; - } -} diff --git a/src/TelegramPanel.Modules.MessageReportTask/MessageReportTaskModule.cs b/src/TelegramPanel.Modules.MessageReportTask/MessageReportTaskModule.cs deleted file mode 100644 index 6c611e4..0000000 --- a/src/TelegramPanel.Modules.MessageReportTask/MessageReportTaskModule.cs +++ /dev/null @@ -1,58 +0,0 @@ -using Microsoft.AspNetCore.Routing; -using Microsoft.Extensions.DependencyInjection; -using MudBlazor; -using TelegramPanel.Modules; -using TelegramPanel.Modules.MessageReportTask.Components.Dialogs; -using TelegramPanel.Modules.MessageReportTask.Services; - -namespace TelegramPanel.Modules.MessageReportTask; - -public sealed class MessageReportTaskModule : ITelegramPanelModule, IModuleTaskProvider -{ - public ModuleManifest Manifest { get; } = new() - { - Id = MessageReportTaskConstants.ModuleId, - Name = "任务:消息举报", - Version = "1.0.0", - Host = new HostCompatibility { Min = "1.31.6", Max = "2.0.0" }, - Entry = new ModuleEntryPoint - { - Assembly = "TelegramPanel.Modules.MessageReportTask.dll", - Type = typeof(MessageReportTaskModule).FullName! - } - }; - - public void ConfigureServices(IServiceCollection services, ModuleHostContext context) - { - services.AddScoped(); - services.AddScoped(); - services.AddSingleton(); - } - - public void MapEndpoints(IEndpointRouteBuilder endpoints, ModuleHostContext context) - { - } - - public IEnumerable GetTasks(ModuleHostContext context) - { - yield return new ModuleTaskDefinition - { - Category = "user", - TaskType = MessageReportTaskConstants.TaskType, - DisplayName = "消息举报(多账号)", - Description = "按账号分类对指定消息链接执行举报,支持间隔、Cron 计划和数量上限。", - Icon = Icons.Material.Filled.Flag, - EditorComponentType = typeof(MessageReportTaskEditor).AssemblyQualifiedName ?? string.Empty, - TaskCenter = new ModuleTaskCenterCapabilities - { - CanPause = true, - CanResume = true, - CanEdit = true, - CanRerun = true, - EditComponentType = typeof(MessageReportTaskEditor).AssemblyQualifiedName ?? string.Empty, - AutoPauseBeforeEdit = true - }, - Order = 150 - }; - } -} diff --git a/src/TelegramPanel.Modules.MessageReportTask/Services/MessageReportTaskHandler.cs b/src/TelegramPanel.Modules.MessageReportTask/Services/MessageReportTaskHandler.cs deleted file mode 100644 index 3b88951..0000000 --- a/src/TelegramPanel.Modules.MessageReportTask/Services/MessageReportTaskHandler.cs +++ /dev/null @@ -1,429 +0,0 @@ -using System.Text.Json; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using TelegramPanel.Core.Services; -using TelegramPanel.Data.Entities; -using TelegramPanel.Modules; - -namespace TelegramPanel.Modules.MessageReportTask.Services; - -public sealed class MessageReportTaskHandler : IModuleTaskHandler -{ - private const int MaxFailureLines = 50; - - public string TaskType => MessageReportTaskConstants.TaskType; - - public async Task ExecuteAsync(IModuleTaskExecutionHost host, CancellationToken cancellationToken) - { - var logger = host.Services.GetRequiredService>(); - var taskManagement = host.Services.GetRequiredService(); - var accountManagement = host.Services.GetRequiredService(); - var reportService = host.Services.GetRequiredService(); - - var config = DeserializeConfig(host.Config); - ValidateAndNormalizeConfig(config); - - var selectedCategoryIds = NormalizeSelectedCategoryIds(config).ToHashSet(); - var allAccounts = (await accountManagement.GetAllAccountsAsync()) - .Where(x => x.IsActive && x.UserId > 0 && x.Category?.ExcludeFromOperations != true) - .Where(x => x.CategoryId.HasValue && selectedCategoryIds.Contains(x.CategoryId.Value)) - .OrderBy(x => x.Id) - .ToList(); - - if (allAccounts.Count() == 0) - throw new InvalidOperationException("所选分类下没有可用执行账号"); - - var accountSlots = new List(); - foreach (var account in allAccounts) - { - cancellationToken.ThrowIfCancellationRequested(); - if (!await host.IsStillRunningAsync(cancellationToken)) - { - config.Canceled = true; - await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(config)); - return; - } - - var slot = new AccountSlot(account); - foreach (var rawLink in config.MessageLinks) - { - cancellationToken.ThrowIfCancellationRequested(); - if (!await host.IsStillRunningAsync(cancellationToken)) - { - config.Canceled = true; - await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(config)); - return; - } - - var resolved = await reportService.ResolveMessageTargetAsync(account.Id, rawLink, cancellationToken); - if (resolved.Success && resolved.Target != null) - { - slot.Targets.Add(new TargetSlot(resolved.Target)); - continue; - } - - AddFailure(config, account, rawLink, NormalizeReason(resolved.Error)); - } - - if (slot.Targets.Count > 0) - accountSlots.Add(slot); - } - - if (accountSlots.Count == 0) - { - config.Error = "没有可用的账号-消息组合(请确认这些账号能访问目标消息)"; - await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(config)); - throw new InvalidOperationException(config.Error); - } - - config.Error = null; - await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(config)); - - var completed = 0; - var failed = 0; - var accountQueueIndex = 0; - var targetQueueIndexByAccountId = new Dictionary(); - var lastProgressPersistAt = DateTime.UtcNow; - - try - { - while (!cancellationToken.IsCancellationRequested && accountSlots.Count > 0) - { - cancellationToken.ThrowIfCancellationRequested(); - - if (!await host.IsStillRunningAsync(cancellationToken)) - { - config.Canceled = true; - break; - } - - if (config.MaxReports > 0 && completed >= config.MaxReports) - break; - - var accountIdx = SelectNextAccountIndex(accountSlots, ref accountQueueIndex); - if (accountIdx < 0) - break; - - var accountSlot = accountSlots[accountIdx]; - var targetIdx = SelectNextTargetIndex(accountSlot, targetQueueIndexByAccountId); - if (targetIdx < 0) - { - accountSlots.RemoveAt(accountIdx); - targetQueueIndexByAccountId.Remove(accountSlot.Account.Id); - continue; - } - - var targetSlot = accountSlot.Targets[targetIdx]; - var result = await reportService.ReportMessageAsync(accountSlot.Account.Id, targetSlot.Target, config, cancellationToken); - - completed++; - var hadFailureThisRound = false; - var shouldRemoveTarget = result.Success || result.ShouldRemoveTarget; - - if (!result.Success) - { - failed++; - hadFailureThisRound = true; - targetSlot.ConsecutiveFailures++; - AddFailure(config, accountSlot.Account, targetSlot.Target.RawInput, NormalizeReason(result.Error)); - if (targetSlot.ConsecutiveFailures >= 3) - shouldRemoveTarget = true; - - await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(config)); - } - - if (shouldRemoveTarget) - { - accountSlot.Targets.RemoveAt(targetIdx); - if (accountSlot.Targets.Count == 0) - { - accountSlots.RemoveAt(accountIdx); - targetQueueIndexByAccountId.Remove(accountSlot.Account.Id); - } - } - - if (ShouldPersistProgress(completed, hadFailureThisRound, lastProgressPersistAt)) - { - await host.UpdateProgressAsync(completed, failed, cancellationToken); - lastProgressPersistAt = DateTime.UtcNow; - } - - if (config.MaxReports > 0 && completed >= config.MaxReports) - break; - - if (accountSlots.Count == 0) - break; - - var delayMs = NextDelayMilliseconds(config.DelayMinMs, config.DelayMaxMs); - if (delayMs > 0) - { - var keepRunning = await DelayWithTaskCheckAsync(host, delayMs, cancellationToken); - if (!keepRunning) - { - config.Canceled = true; - break; - } - } - } - } - catch (Exception ex) - { - logger.LogWarning(ex, "MessageReport task failed (taskId={TaskId})", host.TaskId); - config.Error = ex.Message; - await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(config)); - throw; - } - - if (config.MaxReports > 0 && accountSlots.Count == 0 && completed < host.Total) - await taskManagement.UpdateTaskDraftAsync(host.TaskId, completed, SerializeIndented(config)); - else - await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(config)); - - await host.UpdateProgressAsync(completed, failed, cancellationToken); - if (config.Canceled) - { - await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(config)); - return; - } - - config.Error = null; - await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(config)); - } - - private static MessageReportTaskConfig DeserializeConfig(string? rawConfig) - { - var raw = (rawConfig ?? string.Empty).Trim(); - if (raw.Length == 0) - throw new InvalidOperationException("任务配置为空"); - - try - { - return JsonSerializer.Deserialize(raw) - ?? throw new InvalidOperationException("任务配置解析结果为空"); - } - catch (Exception ex) - { - throw new InvalidOperationException($"任务配置 JSON 无效:{ex.Message}"); - } - } - - private static void ValidateAndNormalizeConfig(MessageReportTaskConfig config) - { - config.CategoryIds = NormalizeSelectedCategoryIds(config); - if (config.CategoryIds.Count == 0) - throw new InvalidOperationException("请至少选择一个执行账号分类"); - - config.CategoryId = config.CategoryIds[0]; - config.CategoryNames = NormalizeCategoryNames(config.CategoryNames, config.CategoryName); - config.CategoryName = config.CategoryNames.FirstOrDefault() ?? config.CategoryName; - - config.MessageLinks = (config.MessageLinks ?? new List()) - .Select(x => (x ?? string.Empty).Trim()) - .Where(x => x.Length > 0) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToList(); - if (config.MessageLinks.Count == 0) - throw new InvalidOperationException("请至少填写一条消息链接"); - - config.OptionKeywords = (config.OptionKeywords ?? new List()) - .Select(x => (x ?? string.Empty).Trim()) - .Where(x => x.Length > 0) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToList(); - - config.ReportPreset = NormalizePreset(config.ReportPreset); - if (config.ReportPreset == MessageReportTaskPresets.Custom && config.OptionKeywords.Count == 0) - throw new InvalidOperationException("选择“自定义关键词”时,至少填写一个关键词"); - - config.Comment = NormalizeNullableText(config.Comment); - if (config.DelayMinMs < 0) config.DelayMinMs = 0; - if (config.DelayMaxMs < 0) config.DelayMaxMs = 0; - if (config.DelayMinMs > 86400000) config.DelayMinMs = 86400000; - if (config.DelayMaxMs > 86400000) config.DelayMaxMs = 86400000; - if (config.DelayMaxMs < config.DelayMinMs) config.DelayMaxMs = config.DelayMinMs; - if (config.MaxReports < 0) config.MaxReports = 0; - - config.Canceled = false; - config.Error = NormalizeNullableText(config.Error); - config.RecentFailures ??= new List(); - } - - private static List NormalizeSelectedCategoryIds(MessageReportTaskConfig config) - { - var ids = (config.CategoryIds ?? new List()) - .Where(x => x > 0) - .Distinct() - .ToList(); - - if (ids.Count == 0 && config.CategoryId > 0) - ids.Add(config.CategoryId); - - return ids; - } - - private static List NormalizeCategoryNames(IEnumerable? values, string? fallback) - { - var names = (values ?? Array.Empty()) - .Select(x => (x ?? string.Empty).Trim()) - .Where(x => x.Length > 0) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToList(); - - var fallbackName = (fallback ?? string.Empty).Trim(); - if (names.Count == 0 && fallbackName.Length > 0) - names.Add(fallbackName); - - return names; - } - - private static string NormalizePreset(string? preset) - { - var value = (preset ?? string.Empty).Trim().ToLowerInvariant(); - return value switch - { - MessageReportTaskPresets.Spam => MessageReportTaskPresets.Spam, - MessageReportTaskPresets.Violence => MessageReportTaskPresets.Violence, - MessageReportTaskPresets.Pornography => MessageReportTaskPresets.Pornography, - MessageReportTaskPresets.ChildAbuse => MessageReportTaskPresets.ChildAbuse, - MessageReportTaskPresets.Copyright => MessageReportTaskPresets.Copyright, - MessageReportTaskPresets.IllegalDrugs => MessageReportTaskPresets.IllegalDrugs, - MessageReportTaskPresets.PersonalDetails => MessageReportTaskPresets.PersonalDetails, - MessageReportTaskPresets.Other => MessageReportTaskPresets.Other, - MessageReportTaskPresets.FirstAvailable => MessageReportTaskPresets.FirstAvailable, - MessageReportTaskPresets.Custom => MessageReportTaskPresets.Custom, - _ => MessageReportTaskPresets.Spam - }; - } - - private static string? NormalizeNullableText(string? value) - { - var text = (value ?? string.Empty).Trim(); - return text.Length == 0 ? null : text; - } - - private static int SelectNextAccountIndex(List slots, ref int queueIndex) - { - if (slots.Count == 0) - return -1; - - if (queueIndex < 0 || queueIndex >= slots.Count) - queueIndex = 0; - - var selected = queueIndex; - queueIndex = (queueIndex + 1) % slots.Count; - return selected; - } - - private static int SelectNextTargetIndex(AccountSlot slot, Dictionary targetQueueIndexByAccountId) - { - if (slot.Targets.Count == 0) - return -1; - - if (!targetQueueIndexByAccountId.TryGetValue(slot.Account.Id, out var index)) - index = 0; - - if (index < 0 || index >= slot.Targets.Count) - index = 0; - - var selected = index; - targetQueueIndexByAccountId[slot.Account.Id] = (selected + 1) % slot.Targets.Count; - return selected; - } - - private static int NextDelayMilliseconds(int min, int max) - { - if (min <= 0 && max <= 0) - return 0; - if (max <= min) - return Math.Max(0, min); - return Random.Shared.Next(min, max + 1); - } - - private static bool ShouldPersistProgress(int completed, bool hadFailureThisRound, DateTime lastPersistAtUtc) - { - if (completed <= 0) - return hadFailureThisRound; - - if (hadFailureThisRound) - return true; - - if (completed % 5 == 0) - return true; - - return (DateTime.UtcNow - lastPersistAtUtc) >= TimeSpan.FromSeconds(10); - } - - private static async Task DelayWithTaskCheckAsync(IModuleTaskExecutionHost host, int delayMs, CancellationToken cancellationToken) - { - if (delayMs <= 0) - return true; - - var remaining = delayMs; - while (remaining > 0) - { - cancellationToken.ThrowIfCancellationRequested(); - if (!await host.IsStillRunningAsync(cancellationToken)) - return false; - - var chunk = Math.Min(remaining, 1000); - await Task.Delay(chunk, cancellationToken); - remaining -= chunk; - } - - return true; - } - - private static void AddFailure(MessageReportTaskConfig config, Account account, string messageLink, string reason) - { - config.RecentFailures ??= new List(); - config.RecentFailures.Insert(0, new MessageReportTaskRuntimeFailure - { - TimeUtc = DateTime.UtcNow, - AccountId = account.Id, - Account = BuildAccountDisplayName(account), - MessageLink = (messageLink ?? string.Empty).Trim(), - Reason = reason - }); - - if (config.RecentFailures.Count > MaxFailureLines) - config.RecentFailures = config.RecentFailures.Take(MaxFailureLines).ToList(); - } - - private static string BuildAccountDisplayName(Account account) - { - var username = string.IsNullOrWhiteSpace(account.Username) ? string.Empty : $"@{account.Username}"; - var nickname = string.IsNullOrWhiteSpace(account.Nickname) ? string.Empty : account.Nickname.Trim(); - return string.Join(" / ", new[] { account.DisplayPhone, username, nickname }.Where(x => !string.IsNullOrWhiteSpace(x))); - } - - private static string NormalizeReason(string? reason) - { - var text = (reason ?? string.Empty).Trim(); - return text.Length == 0 ? "未知错误" : text; - } - - private static string SerializeIndented(MessageReportTaskConfig config) - => JsonSerializer.Serialize(config, new JsonSerializerOptions { WriteIndented = true }); - - private sealed class AccountSlot - { - public AccountSlot(Account account) - { - Account = account; - } - - public Account Account { get; } - public List Targets { get; } = new(); - } - - private sealed class TargetSlot - { - public TargetSlot(TelegramMessageReportService.ResolvedReportMessageTarget target) - { - Target = target; - } - - public TelegramMessageReportService.ResolvedReportMessageTarget Target { get; } - public int ConsecutiveFailures { get; set; } - } -} diff --git a/src/TelegramPanel.Modules.MessageReportTask/Services/MessageReportTaskModels.cs b/src/TelegramPanel.Modules.MessageReportTask/Services/MessageReportTaskModels.cs deleted file mode 100644 index 2b0242d..0000000 --- a/src/TelegramPanel.Modules.MessageReportTask/Services/MessageReportTaskModels.cs +++ /dev/null @@ -1,86 +0,0 @@ -using System.Text.Json.Serialization; - -namespace TelegramPanel.Modules.MessageReportTask.Services; - -public static class MessageReportTaskConstants -{ - public const string ModuleId = "task.message-report"; - public const string TaskType = "user_message_report"; -} - -public static class MessageReportTaskPresets -{ - public const string Spam = "spam"; - public const string Violence = "violence"; - public const string Pornography = "pornography"; - public const string ChildAbuse = "child_abuse"; - public const string Copyright = "copyright"; - public const string IllegalDrugs = "illegal_drugs"; - public const string PersonalDetails = "personal_details"; - public const string Other = "other"; - public const string FirstAvailable = "first_available"; - public const string Custom = "custom"; -} - -public sealed class MessageReportTaskConfig -{ - [JsonPropertyName("category_id")] - public int CategoryId { get; set; } - - [JsonPropertyName("category_name")] - public string? CategoryName { get; set; } - - [JsonPropertyName("category_ids")] - public List CategoryIds { get; set; } = new(); - - [JsonPropertyName("category_names")] - public List CategoryNames { get; set; } = new(); - - [JsonPropertyName("message_links")] - public List MessageLinks { get; set; } = new(); - - [JsonPropertyName("delay_min_ms")] - public int DelayMinMs { get; set; } = 15000; - - [JsonPropertyName("delay_max_ms")] - public int DelayMaxMs { get; set; } = 45000; - - [JsonPropertyName("max_reports")] - public int MaxReports { get; set; } - - [JsonPropertyName("report_preset")] - public string ReportPreset { get; set; } = MessageReportTaskPresets.Spam; - - [JsonPropertyName("option_keywords")] - public List OptionKeywords { get; set; } = new(); - - [JsonPropertyName("comment")] - public string? Comment { get; set; } - - [JsonPropertyName("canceled")] - public bool Canceled { get; set; } - - [JsonPropertyName("error")] - public string? Error { get; set; } - - [JsonPropertyName("recent_failures")] - public List RecentFailures { get; set; } = new(); -} - -public sealed class MessageReportTaskRuntimeFailure -{ - [JsonPropertyName("time_utc")] - public DateTime TimeUtc { get; set; } - - [JsonPropertyName("account_id")] - public int AccountId { get; set; } - - [JsonPropertyName("account")] - public string Account { get; set; } = string.Empty; - - [JsonPropertyName("message_link")] - public string MessageLink { get; set; } = string.Empty; - - [JsonPropertyName("reason")] - public string Reason { get; set; } = string.Empty; -} diff --git a/src/TelegramPanel.Modules.MessageReportTask/Services/MessageReportTaskRerunBuilder.cs b/src/TelegramPanel.Modules.MessageReportTask/Services/MessageReportTaskRerunBuilder.cs deleted file mode 100644 index 8c17012..0000000 --- a/src/TelegramPanel.Modules.MessageReportTask/Services/MessageReportTaskRerunBuilder.cs +++ /dev/null @@ -1,132 +0,0 @@ -using System.Text.Json; -using TelegramPanel.Modules; - -namespace TelegramPanel.Modules.MessageReportTask.Services; - -public sealed class MessageReportTaskRerunBuilder : IModuleTaskRerunBuilder -{ - public string TaskType => MessageReportTaskConstants.TaskType; - - public ModuleTaskCreateRequest Build(ModuleTaskSnapshot task) - { - var rerunConfig = BuildRerunConfig(task.Config); - var total = rerunConfig.MaxReports > 0 ? rerunConfig.MaxReports : 0; - var configJson = JsonSerializer.Serialize(rerunConfig, new JsonSerializerOptions { WriteIndented = true }); - - return new ModuleTaskCreateRequest - { - TaskType = MessageReportTaskConstants.TaskType, - Total = total, - Config = configJson - }; - } - - private static MessageReportTaskConfig BuildRerunConfig(string? rawConfig) - { - var raw = (rawConfig ?? string.Empty).Trim(); - if (raw.Length == 0) - throw new InvalidOperationException("任务配置为空,无法重新运行"); - - MessageReportTaskConfig cfg; - try - { - cfg = JsonSerializer.Deserialize(raw) - ?? throw new InvalidOperationException("任务配置解析结果为空"); - } - catch (Exception ex) - { - throw new InvalidOperationException($"任务配置 JSON 无效:{ex.Message}"); - } - - cfg.CategoryIds = NormalizeCategoryIds(cfg.CategoryIds, cfg.CategoryId); - if (cfg.CategoryIds.Count == 0) - throw new InvalidOperationException("任务缺少账号分类,无法重新运行"); - - cfg.CategoryId = cfg.CategoryIds[0]; - cfg.CategoryNames = NormalizeCategoryNames(cfg.CategoryNames, cfg.CategoryName); - cfg.CategoryName = cfg.CategoryNames.FirstOrDefault() ?? cfg.CategoryName; - - cfg.MessageLinks = (cfg.MessageLinks ?? new List()) - .Select(x => (x ?? string.Empty).Trim()) - .Where(x => x.Length > 0) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToList(); - - if (cfg.MessageLinks.Count == 0) - throw new InvalidOperationException("任务缺少消息链接,无法重新运行"); - - cfg.OptionKeywords = (cfg.OptionKeywords ?? new List()) - .Select(x => (x ?? string.Empty).Trim()) - .Where(x => x.Length > 0) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToList(); - - if (cfg.DelayMinMs < 0) cfg.DelayMinMs = 0; - if (cfg.DelayMaxMs < 0) cfg.DelayMaxMs = 0; - if (cfg.DelayMinMs > 86400000) cfg.DelayMinMs = 86400000; - if (cfg.DelayMaxMs > 86400000) cfg.DelayMaxMs = 86400000; - if (cfg.DelayMaxMs < cfg.DelayMinMs) cfg.DelayMaxMs = cfg.DelayMinMs; - if (cfg.MaxReports < 0) cfg.MaxReports = 0; - - cfg.ReportPreset = NormalizePreset(cfg.ReportPreset); - cfg.Comment = NormalizeNullableText(cfg.Comment); - cfg.Canceled = false; - cfg.Error = null; - cfg.RecentFailures = new List(); - - return cfg; - } - - private static List NormalizeCategoryIds(IEnumerable? values, int fallback) - { - var ids = (values ?? Array.Empty()) - .Where(x => x > 0) - .Distinct() - .ToList(); - - if (ids.Count == 0 && fallback > 0) - ids.Add(fallback); - - return ids; - } - - private static List NormalizeCategoryNames(IEnumerable? values, string? fallback) - { - var names = (values ?? Array.Empty()) - .Select(x => (x ?? string.Empty).Trim()) - .Where(x => x.Length > 0) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToList(); - - var fallbackName = (fallback ?? string.Empty).Trim(); - if (names.Count == 0 && fallbackName.Length > 0) - names.Add(fallbackName); - - return names; - } - - private static string NormalizePreset(string? preset) - { - var value = (preset ?? string.Empty).Trim().ToLowerInvariant(); - return value switch - { - MessageReportTaskPresets.Spam => MessageReportTaskPresets.Spam, - MessageReportTaskPresets.Violence => MessageReportTaskPresets.Violence, - MessageReportTaskPresets.Pornography => MessageReportTaskPresets.Pornography, - MessageReportTaskPresets.ChildAbuse => MessageReportTaskPresets.ChildAbuse, - MessageReportTaskPresets.Copyright => MessageReportTaskPresets.Copyright, - MessageReportTaskPresets.IllegalDrugs => MessageReportTaskPresets.IllegalDrugs, - MessageReportTaskPresets.PersonalDetails => MessageReportTaskPresets.PersonalDetails, - MessageReportTaskPresets.Other => MessageReportTaskPresets.Other, - MessageReportTaskPresets.FirstAvailable => MessageReportTaskPresets.FirstAvailable, - MessageReportTaskPresets.Custom => MessageReportTaskPresets.Custom, - _ => MessageReportTaskPresets.Spam - }; - } - - private static string? NormalizeNullableText(string? value) - { - var text = (value ?? string.Empty).Trim(); - return text.Length == 0 ? null : text; - } -} diff --git a/src/TelegramPanel.Modules.MessageReportTask/Services/TelegramMessageReportService.cs b/src/TelegramPanel.Modules.MessageReportTask/Services/TelegramMessageReportService.cs deleted file mode 100644 index 3a09881..0000000 --- a/src/TelegramPanel.Modules.MessageReportTask/Services/TelegramMessageReportService.cs +++ /dev/null @@ -1,483 +0,0 @@ -using System.Globalization; -using System.Text; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Logging; -using TelegramPanel.Core.Interfaces; -using TelegramPanel.Core.Services.Telegram; -using TL; -using WTelegram; - -namespace TelegramPanel.Modules.MessageReportTask.Services; - -public sealed class TelegramMessageReportService -{ - private readonly AccountTelegramToolsService _accountTools; - private readonly ITelegramClientPool _clientPool; - private readonly IConfiguration _configuration; - private readonly ILogger _logger; - - public TelegramMessageReportService( - AccountTelegramToolsService accountTools, - ITelegramClientPool clientPool, - IConfiguration configuration, - ILogger logger) - { - _accountTools = accountTools; - _clientPool = clientPool; - _configuration = configuration; - _logger = logger; - } - - public sealed record ResolvedReportMessageTarget( - AccountTelegramToolsService.ResolvedChatTarget Chat, - int MessageId, - string CanonicalLink, - string RawInput); - - public sealed record ReportMessageResult( - bool Success, - string? Error, - string? SelectedOptionText, - bool ShouldRemoveTarget); - - private sealed record ParsedMessageLink(string ResolveTarget, int MessageId, string CanonicalLink); - - public async Task<(bool Success, string? Error, ResolvedReportMessageTarget? Target)> ResolveMessageTargetAsync( - int accountId, - string rawInput, - CancellationToken cancellationToken = default) - { - try - { - var parsed = ParseMessageLink(rawInput); - var resolved = await _accountTools.ResolveChatTargetAsync(accountId, parsed.ResolveTarget, cancellationToken); - if (!resolved.Success || resolved.Target == null) - return (false, resolved.Error ?? "无法解析消息链接", null); - - return (true, null, new ResolvedReportMessageTarget(resolved.Target, parsed.MessageId, parsed.CanonicalLink, (rawInput ?? string.Empty).Trim())); - } - catch (Exception ex) - { - var message = ex is ArgumentException or FormatException or InvalidOperationException - ? ex.Message - : BuildTelegramError(ex); - return (false, message, null); - } - } - - public async Task ReportMessageAsync( - int accountId, - ResolvedReportMessageTarget target, - MessageReportTaskConfig config, - CancellationToken cancellationToken = default) - { - try - { - var client = await EnsureConnectedClientAsync(accountId, target.Chat.CanonicalId, cancellationToken); - - byte[] option = Array.Empty(); - var comment = string.Empty; - string? selectedOptionText = null; - - var result = await ExecuteTelegramRequestAsync( - accountId, - $"举报消息({target.CanonicalLink})", - () => client.Messages_Report(target.Chat.Peer, new[] { target.MessageId }, option, comment), - cancellationToken, - resetClientOnTimeout: true); - - for (var depth = 0; depth < 8; depth++) - { - cancellationToken.ThrowIfCancellationRequested(); - - switch (result) - { - case ReportResultReported: - return new ReportMessageResult(true, null, selectedOptionText, true); - - case ReportResultChooseOption choose: - { - var selected = SelectOption(choose.options, config); - if (selected == null) - { - var available = BuildOptionListText(choose.options); - var error = available.Length == 0 - ? "Telegram 返回了空的举报选项列表" - : $"未匹配到举报类型,对应可选项:{available}"; - return new ReportMessageResult(false, error, selectedOptionText, true); - } - - option = selected.option ?? Array.Empty(); - selectedOptionText = NormalizeNullableText(selected.text); - - result = await ExecuteTelegramRequestAsync( - accountId, - $"选择举报类型({selectedOptionText ?? "未命名选项"})", - () => client.Messages_Report(target.Chat.Peer, new[] { target.MessageId }, option, comment), - cancellationToken, - resetClientOnTimeout: true); - break; - } - - case ReportResultAddComment addComment: - { - option = addComment.option ?? option; - comment = BuildComment(config.Comment, target); - var optional = addComment.flags.HasFlag(ReportResultAddComment.Flags.optional); - if (string.IsNullOrWhiteSpace(comment) && !optional) - return new ReportMessageResult(false, "当前举报类型要求填写举报文案,请先补充文案后再执行", selectedOptionText, true); - - result = await ExecuteTelegramRequestAsync( - accountId, - $"提交举报文案({target.CanonicalLink})", - () => client.Messages_Report(target.Chat.Peer, new[] { target.MessageId }, option, comment), - cancellationToken, - resetClientOnTimeout: true); - break; - } - - default: - return new ReportMessageResult(false, $"不支持的举报返回类型:{result.GetType().Name}", selectedOptionText, true); - } - } - - return new ReportMessageResult(false, "举报流程超过安全重试次数,请稍后再试", selectedOptionText, false); - } - catch (Exception ex) - { - var error = BuildTelegramError(ex); - return new ReportMessageResult(false, error, null, ShouldRemoveTargetAfterFailure(error)); - } - } - - private async Task EnsureConnectedClientAsync(int accountId, string resolveHint, CancellationToken cancellationToken) - { - var existing = _clientPool.GetClient(accountId); - if (existing?.User != null) - return existing; - - var resolved = await _accountTools.ResolveChatTargetAsync(accountId, resolveHint, cancellationToken); - if (!resolved.Success) - throw new InvalidOperationException(resolved.Error ?? "无法建立 Telegram 客户端连接"); - - var client = _clientPool.GetClient(accountId); - if (client?.User != null) - return client; - - throw new InvalidOperationException("无法建立 Telegram 客户端连接"); - } - - private static ParsedMessageLink ParseMessageLink(string rawInput) - { - var raw = (rawInput ?? string.Empty).Trim(); - if (raw.Length == 0) - throw new ArgumentException("消息链接为空", nameof(rawInput)); - - if (raw.StartsWith("tg://", StringComparison.OrdinalIgnoreCase) - && Uri.TryCreate(raw, UriKind.Absolute, out var tgUri) - && string.Equals(tgUri.Host, "privatepost", StringComparison.OrdinalIgnoreCase)) - { - var query = ParseQueryString(tgUri.Query); - if (!query.TryGetValue("channel", out var channelText) || string.IsNullOrWhiteSpace(channelText)) - throw new ArgumentException("tg://privatepost 缺少 channel 参数", nameof(rawInput)); - if (!query.TryGetValue("post", out var postText) || string.IsNullOrWhiteSpace(postText)) - throw new ArgumentException("tg://privatepost 缺少 post 参数", nameof(rawInput)); - - var internalId = NormalizeInternalChatId(channelText); - var messageId = ParsePositiveMessageId(postText); - return new ParsedMessageLink($"-100{internalId}", messageId, $"https://t.me/c/{internalId}/{messageId}"); - } - - var normalizedUrl = raw; - if (normalizedUrl.StartsWith("t.me/", StringComparison.OrdinalIgnoreCase) - || normalizedUrl.StartsWith("telegram.me/", StringComparison.OrdinalIgnoreCase)) - { - normalizedUrl = "https://" + normalizedUrl; - } - - if (Uri.TryCreate(normalizedUrl, UriKind.Absolute, out var uri) - && (string.Equals(uri.Host, "t.me", StringComparison.OrdinalIgnoreCase) - || string.Equals(uri.Host, "telegram.me", StringComparison.OrdinalIgnoreCase))) - { - var segments = (uri.AbsolutePath ?? string.Empty) - .Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - return ParseSegments(segments, rawInput); - } - - var plain = raw; - var queryIndex = plain.IndexOfAny(new[] { '?', '#' }); - if (queryIndex >= 0) - plain = plain[..queryIndex]; - - var pathSegments = plain - .Trim('/') - .Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - return ParseSegments(pathSegments, rawInput); - } - - private static ParsedMessageLink ParseSegments(string[] segments, string? rawInput) - { - if (segments.Length >= 3 && string.Equals(segments[0], "c", StringComparison.OrdinalIgnoreCase)) - { - var internalId = NormalizeInternalChatId(segments[1]); - var messageId = ParsePositiveMessageId(segments[2]); - return new ParsedMessageLink($"-100{internalId}", messageId, $"https://t.me/c/{internalId}/{messageId}"); - } - - if (segments.Length >= 3 && (string.Equals(segments[0], "b", StringComparison.OrdinalIgnoreCase) - || string.Equals(segments[0], "s", StringComparison.OrdinalIgnoreCase))) - { - var target = NormalizeTargetToken(segments[1]); - var messageId = ParsePositiveMessageId(segments[2]); - return new ParsedMessageLink(target, messageId, $"https://t.me/{target.TrimStart('@')}/{messageId}"); - } - - if (segments.Length >= 2) - { - var target = NormalizeTargetToken(segments[0]); - var messageId = ParsePositiveMessageId(segments[1]); - if (long.TryParse(target, NumberStyles.Integer, CultureInfo.InvariantCulture, out _)) - return new ParsedMessageLink(target, messageId, $"{target}/{messageId}"); - - return new ParsedMessageLink(target, messageId, $"https://t.me/{target.TrimStart('@')}/{messageId}"); - } - - throw new ArgumentException($"不支持的消息链接格式:{(rawInput ?? string.Empty).Trim()}", nameof(rawInput)); - } - - private static string NormalizeInternalChatId(string value) - { - var text = (value ?? string.Empty).Trim(); - if (text.StartsWith("-100", StringComparison.Ordinal)) - text = text[4..]; - - if (!long.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) || parsed <= 0) - throw new FormatException($"私有消息链接中的频道 ID 无效:{value}"); - - return parsed.ToString(CultureInfo.InvariantCulture); - } - - private static string NormalizeTargetToken(string value) - { - var text = (value ?? string.Empty).Trim(); - if (text.Length == 0) - throw new FormatException("消息链接中缺少目标标识"); - - if (long.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out _)) - return text; - - return text.TrimStart('@'); - } - - private static int ParsePositiveMessageId(string value) - { - var text = (value ?? string.Empty).Trim(); - if (!int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) || parsed <= 0) - throw new FormatException($"消息 ID 无效:{value}"); - return parsed; - } - - private static Dictionary ParseQueryString(string query) - { - var result = new Dictionary(StringComparer.OrdinalIgnoreCase); - var text = (query ?? string.Empty).Trim(); - if (text.StartsWith("?", StringComparison.Ordinal)) - text = text[1..]; - if (text.Length == 0) - return result; - - foreach (var pair in text.Split('&', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) - { - var parts = pair.Split('=', 2); - var key = Uri.UnescapeDataString((parts[0] ?? string.Empty).Trim()); - if (key.Length == 0) - continue; - - var value = parts.Length > 1 ? Uri.UnescapeDataString((parts[1] ?? string.Empty).Trim()) : string.Empty; - result[key] = value; - } - - return result; - } - - private static MessageReportOption? SelectOption(IEnumerable? options, MessageReportTaskConfig config) - { - var list = (options ?? Array.Empty()) - .Where(x => x != null) - .ToList(); - if (list.Count == 0) - return null; - - if (string.Equals(config.ReportPreset, MessageReportTaskPresets.FirstAvailable, StringComparison.OrdinalIgnoreCase)) - return list[0]; - - var normalizedKeywords = BuildNormalizedKeywords(config); - if (normalizedKeywords.Count == 0) - return null; - - foreach (var option in list) - { - var text = NormalizeKeyword(option.text); - if (text.Length == 0) - continue; - - if (normalizedKeywords.Any(keyword => text.Contains(keyword, StringComparison.Ordinal))) - return option; - } - - return null; - } - - private static List BuildNormalizedKeywords(MessageReportTaskConfig config) - { - var custom = (config.OptionKeywords ?? new List()) - .Select(NormalizeKeyword) - .Where(x => x.Length > 0) - .Distinct(StringComparer.Ordinal) - .ToList(); - if (custom.Count > 0) - return custom; - - return GetPresetKeywords(config.ReportPreset) - .Select(NormalizeKeyword) - .Where(x => x.Length > 0) - .Distinct(StringComparer.Ordinal) - .ToList(); - } - - private static IEnumerable GetPresetKeywords(string? preset) - { - return (preset ?? string.Empty).Trim().ToLowerInvariant() switch - { - MessageReportTaskPresets.Spam => new[] { "spam", "垃圾", "骚扰", "广告" }, - MessageReportTaskPresets.Violence => new[] { "violence", "violent", "暴力", "威胁" }, - MessageReportTaskPresets.Pornography => new[] { "porn", "pornography", "色情", "淫秽" }, - MessageReportTaskPresets.ChildAbuse => new[] { "child", "childabuse", "儿童", "未成年人" }, - MessageReportTaskPresets.Copyright => new[] { "copyright", "侵权", "版权" }, - MessageReportTaskPresets.IllegalDrugs => new[] { "drugs", "illegaldrugs", "毒品", "违禁药物" }, - MessageReportTaskPresets.PersonalDetails => new[] { "personal", "personaldetails", "隐私", "个人信息" }, - MessageReportTaskPresets.Other => new[] { "other", "其他" }, - _ => Array.Empty() - }; - } - - private static string BuildComment(string? template, ResolvedReportMessageTarget target) - { - var text = (template ?? string.Empty).Trim(); - if (text.Length == 0) - return string.Empty; - - return text - .Replace("{消息链接}", target.CanonicalLink, StringComparison.OrdinalIgnoreCase) - .Replace("{message_link}", target.CanonicalLink, StringComparison.OrdinalIgnoreCase) - .Replace("{聊天标题}", target.Chat.Title, StringComparison.OrdinalIgnoreCase) - .Replace("{chat_title}", target.Chat.Title, StringComparison.OrdinalIgnoreCase) - .Replace("{消息ID}", target.MessageId.ToString(CultureInfo.InvariantCulture), StringComparison.OrdinalIgnoreCase) - .Replace("{message_id}", target.MessageId.ToString(CultureInfo.InvariantCulture), StringComparison.OrdinalIgnoreCase); - } - - private static string BuildOptionListText(IEnumerable? options) - { - return string.Join(" / ", (options ?? Array.Empty()) - .Select(x => NormalizeNullableText(x?.text)) - .Where(x => !string.IsNullOrWhiteSpace(x)) - .Distinct(StringComparer.OrdinalIgnoreCase)); - } - - private static string NormalizeKeyword(string? value) - { - if (string.IsNullOrWhiteSpace(value)) - return string.Empty; - - var builder = new StringBuilder(value.Length); - foreach (var ch in value.Trim().ToLowerInvariant()) - { - if (char.IsLetterOrDigit(ch) || ch >= 0x4e00) - builder.Append(ch); - } - - return builder.ToString(); - } - - private static string? NormalizeNullableText(string? value) - { - var text = (value ?? string.Empty).Trim(); - return text.Length == 0 ? null : text; - } - - private string BuildTelegramError(Exception ex) - { - var (summary, details) = AccountTelegramToolsService.MapTelegramException(ex); - return string.IsNullOrWhiteSpace(details) ? summary : $"{summary}:{details}"; - } - - private static bool ShouldRemoveTargetAfterFailure(string error) - { - if (string.IsNullOrWhiteSpace(error)) - return false; - - if (LooksLikeTemporaryFailure(error)) - return false; - - return error.Contains("ALREADY_REPORTED", StringComparison.OrdinalIgnoreCase) - || error.Contains("MESSAGE_ALREADY_REPORTED", StringComparison.OrdinalIgnoreCase) - || error.Contains("MESSAGE_ID_INVALID", StringComparison.OrdinalIgnoreCase) - || error.Contains("MSG_ID_INVALID", StringComparison.OrdinalIgnoreCase) - || error.Contains("MESSAGE_NOT_FOUND", StringComparison.OrdinalIgnoreCase) - || error.Contains("CHAT_ID_INVALID", StringComparison.OrdinalIgnoreCase) - || error.Contains("PEER_ID_INVALID", StringComparison.OrdinalIgnoreCase) - || error.Contains("CHANNEL_INVALID", StringComparison.OrdinalIgnoreCase) - || error.Contains("CHANNEL_PRIVATE", StringComparison.OrdinalIgnoreCase) - || error.Contains("GROUPED_MEDIA_INVALID", StringComparison.OrdinalIgnoreCase) - || error.Contains("未匹配到举报类型", StringComparison.OrdinalIgnoreCase) - || error.Contains("要求填写举报文案", StringComparison.OrdinalIgnoreCase) - || error.Contains("不支持的举报返回类型", StringComparison.OrdinalIgnoreCase) - || error.Contains("链接格式", StringComparison.OrdinalIgnoreCase); - } - - private static bool LooksLikeTemporaryFailure(string error) - { - return error.Contains("FLOOD_WAIT", StringComparison.OrdinalIgnoreCase) - || error.Contains("请求超时", StringComparison.OrdinalIgnoreCase) - || error.Contains("timeout", StringComparison.OrdinalIgnoreCase) - || error.Contains("timed out", StringComparison.OrdinalIgnoreCase) - || error.Contains("网络", StringComparison.OrdinalIgnoreCase) - || error.Contains("NETWORK", StringComparison.OrdinalIgnoreCase); - } - - private TimeSpan GetTelegramRequestTimeout() - { - var seconds = int.TryParse(_configuration["Telegram:RequestTimeoutSeconds"], out var parsedSeconds) - ? parsedSeconds - : 90; - return TimeSpan.FromSeconds(Math.Clamp(seconds, 15, 600)); - } - - private async Task ExecuteTelegramRequestAsync( - int accountId, - string operation, - Func> action, - CancellationToken cancellationToken, - bool resetClientOnTimeout) - { - var timeout = GetTelegramRequestTimeout(); - - try - { - return await action().WaitAsync(timeout, cancellationToken); - } - catch (TimeoutException) - { - _logger.LogWarning( - "Telegram request timed out after {TimeoutSeconds}s for account {AccountId}: {Operation}", - timeout.TotalSeconds, - accountId, - operation); - - if (resetClientOnTimeout) - await _clientPool.RemoveClientAsync(accountId); - - throw new TimeoutException($"Telegram 请求超时:{operation} 超过 {timeout.TotalSeconds:0} 秒,可能是账号受限、网络异常或代理异常"); - } - } -} diff --git a/src/TelegramPanel.Modules.MessageReportTask/TelegramPanel.Modules.MessageReportTask.csproj b/src/TelegramPanel.Modules.MessageReportTask/TelegramPanel.Modules.MessageReportTask.csproj deleted file mode 100644 index 805a71e..0000000 --- a/src/TelegramPanel.Modules.MessageReportTask/TelegramPanel.Modules.MessageReportTask.csproj +++ /dev/null @@ -1,33 +0,0 @@ - - - net8.0 - enable - enable - TelegramPanel.Modules.MessageReportTask - TelegramPanel.Modules.MessageReportTask - - - - - - - - - - - - - - - - - - Never - Never - - - Never - Never - - - diff --git a/src/TelegramPanel.Modules.MessageReportTask/_Imports.razor b/src/TelegramPanel.Modules.MessageReportTask/_Imports.razor deleted file mode 100644 index 1ae409c..0000000 --- a/src/TelegramPanel.Modules.MessageReportTask/_Imports.razor +++ /dev/null @@ -1,5 +0,0 @@ -@using Microsoft.AspNetCore.Components -@using MudBlazor -@using TelegramPanel.Core.Services -@using TelegramPanel.Data.Entities -@using TelegramPanel.Modules diff --git a/src/TelegramPanel.Modules.MessageReportTask/manifest.json b/src/TelegramPanel.Modules.MessageReportTask/manifest.json deleted file mode 100644 index 7b1c89b..0000000 --- a/src/TelegramPanel.Modules.MessageReportTask/manifest.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "id": "task.message-report", - "name": "任务:消息举报", - "version": "1.0.0", - "host": { - "min": "1.31.6", - "max": "2.0.0" - }, - "entry": { - "assembly": "TelegramPanel.Modules.MessageReportTask.dll", - "type": "TelegramPanel.Modules.MessageReportTask.MessageReportTaskModule" - } -} From 53ef067f372759090e9f1ef4d294a7629486b637 Mon Sep 17 00:00:00 2001 From: meoacgx Date: Tue, 10 Mar 2026 03:49:02 +0800 Subject: [PATCH 08/13] feat: add AI verification for user chat active task --- .../Models/TelegramAccountMessageUpdate.cs | 27 ++ .../Models/TelegramVerificationModels.cs | 12 + .../ServiceCollectionExtensions.cs | 2 + .../Telegram/AccountTelegramToolsService.cs | 230 ++++++++++++++- .../Telegram/TelegramAccountUpdateHub.cs | 92 ++++++ .../Services/Telegram/TelegramClientPool.cs | 101 ++++++- .../AiServices.cs | 52 ++++ .../Dialogs/UserChatActiveTaskEditor.razor | 126 ++++++++- .../Components/Pages/Settings.razor | 86 +++++- src/TelegramPanel.Web/Program.cs | 5 + .../Services/AiOpenAiOptions.cs | 187 +++++++++++++ .../Services/TelegramPanelAiService.cs | 263 ++++++++++++++++++ .../UserChatActiveAiVerificationService.cs | 137 +++++++++ .../Services/UserChatActiveTaskHandler.cs | 52 +++- .../Services/UserChatActiveTaskModels.cs | 9 + .../UserChatActiveTaskRerunBuilder.cs | 4 + 16 files changed, 1375 insertions(+), 10 deletions(-) create mode 100644 src/TelegramPanel.Core/Models/TelegramAccountMessageUpdate.cs create mode 100644 src/TelegramPanel.Core/Models/TelegramVerificationModels.cs create mode 100644 src/TelegramPanel.Core/Services/Telegram/TelegramAccountUpdateHub.cs create mode 100644 src/TelegramPanel.Modules.Abstractions/AiServices.cs create mode 100644 src/TelegramPanel.Web/Services/AiOpenAiOptions.cs create mode 100644 src/TelegramPanel.Web/Services/TelegramPanelAiService.cs create mode 100644 src/TelegramPanel.Web/Services/UserChatActiveAiVerificationService.cs diff --git a/src/TelegramPanel.Core/Models/TelegramAccountMessageUpdate.cs b/src/TelegramPanel.Core/Models/TelegramAccountMessageUpdate.cs new file mode 100644 index 0000000..bb93708 --- /dev/null +++ b/src/TelegramPanel.Core/Models/TelegramAccountMessageUpdate.cs @@ -0,0 +1,27 @@ +using TL; + +namespace TelegramPanel.Core.Models; + +public sealed record TelegramInlineButton( + int Index, + int RowIndex, + int ColumnIndex, + string Text, + byte[] CallbackData); + +public sealed record TelegramAccountMessageUpdate( + int AccountId, + DateTimeOffset ReceivedAtUtc, + bool IsEdited, + Message Message, + long? SenderUserId, + string? SenderUsername, + bool SenderIsBot, + int? ReplyToMessageId, + int? ThreadId, + IReadOnlyList Buttons) +{ + public string Text => (Message.message ?? string.Empty).Trim(); + + public bool HasVisualMedia => Message.media is MessageMediaPhoto or MessageMediaDocument; +} diff --git a/src/TelegramPanel.Core/Models/TelegramVerificationModels.cs b/src/TelegramPanel.Core/Models/TelegramVerificationModels.cs new file mode 100644 index 0000000..8bcb839 --- /dev/null +++ b/src/TelegramPanel.Core/Models/TelegramVerificationModels.cs @@ -0,0 +1,12 @@ +namespace TelegramPanel.Core.Models; + +public sealed record TelegramInlineButtonOption(int Index, string Text, byte[] CallbackData); + +public sealed record TelegramVerificationMessageCandidate( + int MessageId, + string? Text, + byte[]? ImageJpegBytes, + IReadOnlyList Buttons, + bool MentionsAccount, + bool IsReplyToSentMessage, + DateTime DateUtc); diff --git a/src/TelegramPanel.Core/ServiceCollectionExtensions.cs b/src/TelegramPanel.Core/ServiceCollectionExtensions.cs index cf9d40b..32650f2 100644 --- a/src/TelegramPanel.Core/ServiceCollectionExtensions.cs +++ b/src/TelegramPanel.Core/ServiceCollectionExtensions.cs @@ -12,6 +12,8 @@ public static class ServiceCollectionExtensions { public static IServiceCollection AddTelegramPanelCore(this IServiceCollection services) { + services.AddSingleton(); + // 注册 Telegram 客户端池(单例) services.AddSingleton(); diff --git a/src/TelegramPanel.Core/Services/Telegram/AccountTelegramToolsService.cs b/src/TelegramPanel.Core/Services/Telegram/AccountTelegramToolsService.cs index 39e8318..c605c68 100644 --- a/src/TelegramPanel.Core/Services/Telegram/AccountTelegramToolsService.cs +++ b/src/TelegramPanel.Core/Services/Telegram/AccountTelegramToolsService.cs @@ -24,17 +24,20 @@ public class AccountTelegramToolsService private readonly ITelegramClientPool _clientPool; private readonly IConfiguration _configuration; private readonly ILogger _logger; + private readonly TelegramAccountUpdateHub _updateHub; public AccountTelegramToolsService( AccountManagementService accountManagement, ITelegramClientPool clientPool, IConfiguration configuration, - ILogger logger) + ILogger logger, + TelegramAccountUpdateHub updateHub) { _accountManagement = accountManagement; _clientPool = clientPool; _configuration = configuration; _logger = logger; + _updateHub = updateHub; } /// @@ -1112,22 +1115,99 @@ public class AccountTelegramToolsService /// /// 向已解析的群组/频道目标发送文本消息。 /// - public async Task<(bool Success, string? Error)> SendMessageToResolvedChatAsync( + public async Task<(bool Success, string? Error, int? MessageId)> SendMessageToResolvedChatAsync( int accountId, ResolvedChatTarget target, string message, + int? replyToMessageId = null, CancellationToken cancellationToken = default) { try { var text = (message ?? string.Empty).Trim(); if (text.Length == 0) - return (false, "消息内容为空"); + return (false, "消息内容为空", null); var client = await GetOrCreateConnectedClientAsync(accountId, cancellationToken); cancellationToken.ThrowIfCancellationRequested(); - _ = await client.SendMessageAsync(target.Peer, text); + var sent = await client.SendMessageAsync(target.Peer, text, null, replyToMessageId ?? 0); + return (true, null, sent.id); + } + catch (Exception ex) + { + var (summary, details) = MapTelegramException(ex); + var msg = string.IsNullOrWhiteSpace(details) ? summary : $"{summary}:{details}"; + return (false, msg, null); + } + } + + public async Task<(bool Success, string? Error, TelegramVerificationMessageCandidate? Candidate)> WaitForBotVerificationMessageAsync( + int accountId, + ResolvedChatTarget target, + int sentMessageId, + string? currentUsername, + int timeoutSeconds, + CancellationToken cancellationToken = default) + { + if (timeoutSeconds < 3) + timeoutSeconds = 3; + if (timeoutSeconds > 300) + timeoutSeconds = 300; + + try + { + var client = await GetOrCreateConnectedClientAsync(accountId, cancellationToken); + var waitStartedAt = DateTimeOffset.UtcNow.AddSeconds(-2); + var update = await _updateHub.WaitForAsync( + accountId, + x => IsCandidateVerificationMessage(x, target, currentUsername, sentMessageId), + waitStartedAt, + TimeSpan.FromSeconds(timeoutSeconds), + cancellationToken); + + if (update == null) + return (false, $"等待验证消息超时({timeoutSeconds} 秒)", null); + + var candidate = await BuildVerificationCandidateAsync( + client, + update.Message, + currentUsername, + sentMessageId, + cancellationToken); + + return candidate == null + ? (false, "匹配到的验证消息为空,无法执行 AI 识别", null) + : (true, null, candidate); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + var (summary, details) = MapTelegramException(ex); + var msg = string.IsNullOrWhiteSpace(details) ? summary : $"{summary}:{details}"; + return (false, msg, null); + } + } + + public async Task<(bool Success, string? Error)> ClickInlineButtonAsync( + int accountId, + ResolvedChatTarget target, + int messageId, + byte[] callbackData, + CancellationToken cancellationToken = default) + { + try + { + if (callbackData == null || callbackData.Length == 0) + return (false, "按钮缺少 callback_data"); + + var client = await GetOrCreateConnectedClientAsync(accountId, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + + _ = await client.Messages_GetBotCallbackAnswer(target.Peer, messageId, callbackData, null, false); return (true, null); } catch (Exception ex) @@ -1180,6 +1260,148 @@ public class AccountTelegramToolsService return null; } + private bool IsCandidateVerificationMessage( + TelegramAccountMessageUpdate update, + ResolvedChatTarget target, + string? currentUsername, + int sentMessageId) + { + if (!update.SenderIsBot) + return false; + + if (!IsSamePeer(target.Peer, update.Message.peer_id)) + return false; + + var mentionsAccount = ContainsUsernameMention(update.Message.message, currentUsername); + var replyToSent = update.ReplyToMessageId == sentMessageId; + return mentionsAccount || replyToSent; + } + + private async Task BuildVerificationCandidateAsync( + Client client, + Message message, + string? currentUsername, + int sentMessageId, + CancellationToken cancellationToken) + { + var buttons = ExtractInlineButtons(message); + var imageJpegBytes = await TryDownloadVerificationImageAsync(client, message, cancellationToken); + var text = (message.message ?? string.Empty).Trim(); + + if (buttons.Count == 0 && text.Length == 0 && (imageJpegBytes == null || imageJpegBytes.Length == 0)) + return null; + + return new TelegramVerificationMessageCandidate( + MessageId: message.id, + Text: text.Length == 0 ? null : text, + ImageJpegBytes: imageJpegBytes, + Buttons: buttons, + MentionsAccount: ContainsUsernameMention(message.message, currentUsername), + IsReplyToSentMessage: message.ReplyHeader?.reply_to_msg_id == sentMessageId, + DateUtc: message.Date.ToUniversalTime()); + } + + private static bool IsSamePeer(InputPeer targetPeer, Peer actualPeer) + { + return (targetPeer, actualPeer) switch + { + (InputPeerChannel targetChannel, PeerChannel actualChannel) => targetChannel.channel_id == actualChannel.channel_id, + (InputPeerChat targetChat, PeerChat actualChat) => targetChat.chat_id == actualChat.chat_id, + (InputPeerUser targetUser, PeerUser actualUser) => targetUser.user_id == actualUser.user_id, + _ => false + }; + } + + private static bool ContainsUsernameMention(string? text, string? currentUsername) + { + var username = (currentUsername ?? string.Empty).Trim().TrimStart('@'); + if (username.Length == 0) + return false; + + var messageText = (text ?? string.Empty).Trim(); + if (messageText.Length == 0) + return false; + + return messageText.Contains($"@{username}", StringComparison.OrdinalIgnoreCase); + } + + private static List ExtractInlineButtons(Message message) + { + if (message.reply_markup is not ReplyInlineMarkup markup) + return new List(); + + var result = new List(); + var index = 0; + foreach (var row in markup.rows ?? Array.Empty()) + { + var buttons = row?.buttons; + if (buttons == null || buttons.Length == 0) + continue; + + foreach (var button in buttons) + { + if (button is KeyboardButtonCallback callback && callback.data is { Length: > 0 }) + { + result.Add(new TelegramInlineButtonOption(index, callback.text ?? string.Empty, callback.data)); + index++; + } + } + } + + return result; + } + + private async Task TryDownloadVerificationImageAsync(Client client, Message message, CancellationToken cancellationToken) + { + try + { + return message.media switch + { + MessageMediaPhoto { photo: Photo photo } => await DownloadPhotoAsJpegAsync(client, photo, cancellationToken), + MessageMediaDocument { document: Document document } => await DownloadDocumentPreviewAsJpegAsync(client, document, cancellationToken), + _ => null + }; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to download verification image from Telegram message {MessageId}", message.id); + return null; + } + } + + private async Task DownloadPhotoAsJpegAsync(Client client, Photo photo, CancellationToken cancellationToken) + { + await using var raw = new MemoryStream(); + await client.DownloadFileAsync(photo, raw, (PhotoSizeBase?)null); + raw.Position = 0; + + await using var jpeg = await TelegramImageProcessor.PrepareStoredImageJpegAsync(raw, cancellationToken: cancellationToken); + return jpeg.ToArray(); + } + + private async Task DownloadDocumentPreviewAsJpegAsync(Client client, Document document, CancellationToken cancellationToken) + { + await using var raw = new MemoryStream(); + + var thumb = document.thumbs?.OfType().LastOrDefault(); + if (thumb != null) + { + await client.DownloadFileAsync(document, raw, thumb); + } + else if ((document.mime_type ?? string.Empty).StartsWith("image/", StringComparison.OrdinalIgnoreCase)) + { + await client.DownloadFileAsync(document, raw, (PhotoSizeBase?)null); + } + else + { + return null; + } + + raw.Position = 0; + await using var jpeg = await TelegramImageProcessor.PrepareStoredImageJpegAsync(raw, cancellationToken: cancellationToken); + return jpeg.ToArray(); + } + private static bool TryParseChatIdCandidate(string raw, out long normalizedId) { normalizedId = 0; diff --git a/src/TelegramPanel.Core/Services/Telegram/TelegramAccountUpdateHub.cs b/src/TelegramPanel.Core/Services/Telegram/TelegramAccountUpdateHub.cs new file mode 100644 index 0000000..17bb18f --- /dev/null +++ b/src/TelegramPanel.Core/Services/Telegram/TelegramAccountUpdateHub.cs @@ -0,0 +1,92 @@ +using System.Collections.Concurrent; +using TelegramPanel.Core.Models; + +namespace TelegramPanel.Core.Services.Telegram; + +/// +/// 聚合账号侧收到的 Telegram 消息更新,供后台任务按账号/聊天做短期等待。 +/// +public sealed class TelegramAccountUpdateHub +{ + private static readonly TimeSpan MessageRetention = TimeSpan.FromMinutes(10); + private const int MaxMessagesPerAccount = 200; + + private readonly ConcurrentDictionary> _messagesByAccount = new(); + + public Task PublishAsync(TelegramAccountMessageUpdate update) + { + var queue = _messagesByAccount.GetOrAdd(update.AccountId, _ => new ConcurrentQueue()); + queue.Enqueue(update); + TrimQueue(queue); + return Task.CompletedTask; + } + + public IReadOnlyList GetRecentMessages(int accountId, DateTimeOffset sinceUtc) + { + if (!_messagesByAccount.TryGetValue(accountId, out var queue)) + return Array.Empty(); + + return queue + .ToArray() + .Where(x => x.ReceivedAtUtc >= sinceUtc) + .OrderBy(x => x.ReceivedAtUtc) + .ToList(); + } + + public async Task WaitForAsync( + int accountId, + Func predicate, + DateTimeOffset sinceUtc, + TimeSpan timeout, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(predicate); + + if (timeout <= TimeSpan.Zero) + timeout = TimeSpan.FromSeconds(1); + + var deadline = DateTimeOffset.UtcNow + timeout; + var seen = new HashSet(StringComparer.Ordinal); + + while (DateTimeOffset.UtcNow < deadline) + { + cancellationToken.ThrowIfCancellationRequested(); + + var items = GetRecentMessages(accountId, sinceUtc); + for (var index = items.Count - 1; index >= 0; index--) + { + var item = items[index]; + var key = $"{item.Message.id}:{item.ReceivedAtUtc.UtcTicks}:{(item.IsEdited ? 1 : 0)}"; + if (!seen.Add(key)) + continue; + + if (predicate(item)) + return item; + } + + var remaining = deadline - DateTimeOffset.UtcNow; + if (remaining <= TimeSpan.Zero) + break; + + var delay = remaining < TimeSpan.FromMilliseconds(300) + ? remaining + : TimeSpan.FromMilliseconds(300); + await Task.Delay(delay, cancellationToken); + } + + return null; + } + + private static void TrimQueue(ConcurrentQueue queue) + { + var cutoff = DateTimeOffset.UtcNow - MessageRetention; + + while (queue.TryPeek(out var item)) + { + if (queue.Count <= MaxMessagesPerAccount && item.ReceivedAtUtc >= cutoff) + break; + + queue.TryDequeue(out _); + } + } +} diff --git a/src/TelegramPanel.Core/Services/Telegram/TelegramClientPool.cs b/src/TelegramPanel.Core/Services/Telegram/TelegramClientPool.cs index d48e398..e2ee618 100644 --- a/src/TelegramPanel.Core/Services/Telegram/TelegramClientPool.cs +++ b/src/TelegramPanel.Core/Services/Telegram/TelegramClientPool.cs @@ -5,6 +5,8 @@ using System.Threading; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using TelegramPanel.Core.Interfaces; +using TelegramPanel.Core.Models; +using TL; using WTelegram; namespace TelegramPanel.Core.Services.Telegram; @@ -19,13 +21,19 @@ public class TelegramClientPool : ITelegramClientPool, IDisposable private readonly ConcurrentDictionary _locks = new(); private readonly IConfiguration _configuration; private readonly ILogger _logger; + private readonly TelegramAccountUpdateHub _updateHub; private bool _disposed; private static int _wtelegramLogConfigured; + private readonly ConcurrentDictionary _updateManagers = new(); - public TelegramClientPool(IConfiguration configuration, ILogger logger) + public TelegramClientPool( + IConfiguration configuration, + ILogger logger, + TelegramAccountUpdateHub updateHub) { _configuration = configuration; _logger = logger; + _updateHub = updateHub; ConfigureWTelegramLoggingOnce(); } @@ -99,6 +107,10 @@ public class TelegramClientPool : ITelegramClientPool, IDisposable var client = new Client(Config); TryRebindApiIdForLoadedSession(client, apiId); + UpdateManager? updateManager = null; + updateManager = client.WithUpdateManager(update => HandleTelegramUpdateAsync(accountId, updateManager!, update)); + _updateManagers[accountId] = updateManager; + // 设置日志回调 client.OnOther += (update) => { @@ -149,6 +161,7 @@ public class TelegramClientPool : ITelegramClientPool, IDisposable if (_clients.TryRemove(accountId, out var client)) { _logger.LogInformation("Removing Telegram client for account {AccountId}", accountId); + _updateManagers.TryRemove(accountId, out _); try { @@ -184,6 +197,7 @@ public class TelegramClientPool : ITelegramClientPool, IDisposable } _clients.Clear(); + _updateManagers.Clear(); foreach (var lockObj in _locks.Values) { @@ -285,4 +299,89 @@ public class TelegramClientPool : ITelegramClientPool, IDisposable _logger.LogDebug("WTelegram({Level}): {Message}", level, message); }; } + + private async Task HandleTelegramUpdateAsync(int accountId, UpdateManager updateManager, Update update) + { + switch (update) + { + case UpdateNewChannelMessage { message: Message message }: + await PublishMessageUpdateAsync(accountId, updateManager, message, isEdited: false); + return; + case UpdateNewMessage { message: Message message }: + await PublishMessageUpdateAsync(accountId, updateManager, message, isEdited: false); + return; + case UpdateEditChannelMessage { message: Message message }: + await PublishMessageUpdateAsync(accountId, updateManager, message, isEdited: true); + return; + case UpdateEditMessage { message: Message message }: + await PublishMessageUpdateAsync(accountId, updateManager, message, isEdited: true); + return; + } + } + + private async Task PublishMessageUpdateAsync(int accountId, UpdateManager updateManager, Message message, bool isEdited) + { + try + { + var senderUserId = (message.from_id as PeerUser)?.user_id; + User? sender = null; + if (senderUserId.HasValue + && updateManager.Users.TryGetValue(senderUserId.Value, out var userBase) + && userBase is User user) + { + sender = user; + } + + var buttons = ExtractInlineButtons(message.reply_markup); + var reply = message.reply_to as MessageReplyHeader; + var update = new TelegramAccountMessageUpdate( + AccountId: accountId, + ReceivedAtUtc: DateTimeOffset.UtcNow, + IsEdited: isEdited, + Message: message, + SenderUserId: senderUserId, + SenderUsername: sender?.username, + SenderIsBot: sender?.IsBot == true, + ReplyToMessageId: reply?.reply_to_msg_id, + ThreadId: reply?.reply_to_top_id, + Buttons: buttons); + + await _updateHub.PublishAsync(update); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to publish Telegram message update (accountId={AccountId}, messageId={MessageId})", accountId, message.id); + } + } + + private static IReadOnlyList ExtractInlineButtons(ReplyMarkup? replyMarkup) + { + if (replyMarkup is not ReplyInlineMarkup inlineMarkup || inlineMarkup.rows == null || inlineMarkup.rows.Length == 0) + return Array.Empty(); + + var result = new List(); + var index = 0; + for (var rowIndex = 0; rowIndex < inlineMarkup.rows.Length; rowIndex++) + { + var row = inlineMarkup.rows[rowIndex]; + var buttons = row?.buttons; + if (buttons == null || buttons.Length == 0) + continue; + + for (var columnIndex = 0; columnIndex < buttons.Length; columnIndex++) + { + if (buttons[columnIndex] is not KeyboardButtonCallback callback) + continue; + + var text = (callback.text ?? string.Empty).Trim(); + if (text.Length == 0 || callback.data == null || callback.data.Length == 0) + continue; + + result.Add(new TelegramInlineButton(index, rowIndex, columnIndex, text, callback.data)); + index++; + } + } + + return result; + } } diff --git a/src/TelegramPanel.Modules.Abstractions/AiServices.cs b/src/TelegramPanel.Modules.Abstractions/AiServices.cs new file mode 100644 index 0000000..45c36ea --- /dev/null +++ b/src/TelegramPanel.Modules.Abstractions/AiServices.cs @@ -0,0 +1,52 @@ +namespace TelegramPanel.Modules; + +/// +/// 面板统一 AI 服务接口,供宿主任务与扩展模块复用。 +/// +public interface ITelegramPanelAiService +{ + /// + /// 根据消息文本、按钮列表与可选图片,决定下一步动作。 + /// + Task ChooseActionAsync( + TelegramPanelAiChooseActionRequest request, + CancellationToken cancellationToken = default); + + /// + /// 根据题目或上下文生成要发送的文本答案。 + /// + Task ReplyTextAsync( + TelegramPanelAiReplyTextRequest request, + CancellationToken cancellationToken = default); +} + +public sealed record TelegramPanelAiButtonOption(int Index, string Text); + +public sealed record TelegramPanelAiImageInput(byte[] Content, string MimeType = "image/jpeg"); + +public sealed record TelegramPanelAiChooseActionRequest( + string? Model, + string? MessageText, + IReadOnlyList Buttons, + TelegramPanelAiImageInput? Image, + string? Context = null); + +public sealed record TelegramPanelAiChooseActionResult( + bool Success, + string? Mode, + int? ButtonIndex, + string? ReplyText, + string? Reason, + string? Error); + +public sealed record TelegramPanelAiReplyTextRequest( + string? Model, + string Prompt, + string Query, + TelegramPanelAiImageInput? Image = null, + string? Context = null); + +public sealed record TelegramPanelAiReplyTextResult( + bool Success, + string? ReplyText, + string? Error); diff --git a/src/TelegramPanel.Web/Components/Dialogs/UserChatActiveTaskEditor.razor b/src/TelegramPanel.Web/Components/Dialogs/UserChatActiveTaskEditor.razor index b7f9538..5e282d2 100644 --- a/src/TelegramPanel.Web/Components/Dialogs/UserChatActiveTaskEditor.razor +++ b/src/TelegramPanel.Web/Components/Dialogs/UserChatActiveTaskEditor.razor @@ -1,6 +1,7 @@ @inject AccountCategoryManagementService CategoryManagement @inject DataDictionaryService DictionaryService @inject TemplateRenderingService TemplateRendering +@inject Microsoft.Extensions.Options.IOptionsMonitor AiOptionsMonitor @using System.Text.Json @using TelegramPanel.Data.Entities @using TelegramPanel.Modules @@ -50,7 +51,7 @@ @if (textDictionaryNames.Count > 0) { - 可用文本变量:{time}、@string.Join("、", textDictionaryNames.Select(x => $"{{{x}}}")) + 可用文本变量:{time}、@(string.Join("、", textDictionaryNames.Select(x => $"{{{x}}}"))) } else { @@ -96,6 +97,46 @@ + + + + + + @if (enableAiVerification) + { + + + + @UseGlobalDefaultModelLabel + @foreach (var model in selectableAiModels) + { + @model + } + 自定义模型 + + + + + + + + @if (IsCustomAiModelSelected) + { + + } + + + 仅处理同一目标群里的 bot 验证消息,并要求它 @当前执行账号 或 直接回复刚发出的消息。 + 有按钮时会优先让 AI 决定点击哪个按钮;没有按钮时会让 AI 直接生成回复文本。 + + } } @@ -104,8 +145,12 @@ [Parameter] public EventCallback DraftChanged { get; set; } [Parameter] public string? InitialConfigJson { get; set; } + private const string UseGlobalDefaultModelOption = "__use_global_default__"; + private const string CustomModelOption = "__custom_model__"; + private List categories = new(); private List textDictionaryNames = new(); + private List selectableAiModels = new(); private bool loading = true; private bool initializedFromConfig; @@ -115,11 +160,20 @@ private int delayMinMs = 15; private int delayMaxMs = 45; private int maxMessages; + private bool enableAiVerification; + private int verificationTimeoutSeconds = 15; + private string? initialAiModel; + private string selectedAiModelOption = UseGlobalDefaultModelOption; + private string customAiModel = string.Empty; + private string globalDefaultAiModelLabel = "使用全局默认(未配置)"; private string accountMode = UserChatActiveTaskModes.Random; private string targetMode = UserChatActiveTaskModes.Queue; private string messageMode = UserChatActiveTaskModes.Random; + private bool IsCustomAiModelSelected => string.Equals(selectedAiModelOption, CustomModelOption, StringComparison.Ordinal); + private string UseGlobalDefaultModelLabel => globalDefaultAiModelLabel; + protected override async Task OnInitializedAsync() { ApplyInitialConfigIfNeeded(); @@ -137,6 +191,8 @@ .Select(x => x.Name) .OrderBy(x => x, StringComparer.OrdinalIgnoreCase) .ToList(); + + LoadAiSettings(); } finally { @@ -172,6 +228,9 @@ accountMode = NormalizeMode(cfg.AccountMode); targetMode = NormalizeMode(cfg.TargetMode); messageMode = NormalizeMode(cfg.MessageMode); + enableAiVerification = cfg.EnableAiVerification; + verificationTimeoutSeconds = cfg.VerificationTimeoutSeconds < 3 ? 15 : cfg.VerificationTimeoutSeconds; + initialAiModel = AiOpenAiSettingsSnapshot.NormalizeModel(cfg.AiModel); } catch { @@ -179,6 +238,50 @@ } } + private void LoadAiSettings() + { + var snapshot = AiOptionsMonitor.CurrentValue.ToSnapshot(); + selectableAiModels = snapshot.GetSelectableModels().ToList(); + var defaultModel = AiOpenAiSettingsSnapshot.NormalizeModel(snapshot.DefaultModel); + globalDefaultAiModelLabel = string.IsNullOrWhiteSpace(defaultModel) + ? "使用全局默认(未配置)" + : $"使用全局默认({defaultModel})"; + + ApplyAiModelSelection(initialAiModel); + } + + private void ApplyAiModelSelection(string? model) + { + var normalized = AiOpenAiSettingsSnapshot.NormalizeModel(model); + if (string.IsNullOrWhiteSpace(normalized)) + { + selectedAiModelOption = UseGlobalDefaultModelOption; + customAiModel = string.Empty; + return; + } + + if (selectableAiModels.Any(x => string.Equals(x, normalized, StringComparison.OrdinalIgnoreCase))) + { + selectedAiModelOption = selectableAiModels.First(x => string.Equals(x, normalized, StringComparison.OrdinalIgnoreCase)); + customAiModel = string.Empty; + return; + } + + selectedAiModelOption = CustomModelOption; + customAiModel = normalized; + } + + private string? ResolveSelectedAiModel() + { + if (string.Equals(selectedAiModelOption, UseGlobalDefaultModelOption, StringComparison.Ordinal)) + return null; + + if (string.Equals(selectedAiModelOption, CustomModelOption, StringComparison.Ordinal)) + return AiOpenAiSettingsSnapshot.NormalizeModel(customAiModel); + + return AiOpenAiSettingsSnapshot.NormalizeModel(selectedAiModelOption); + } + private async Task OnSelectedCategoriesChanged(IEnumerable values) { selectedCategoryIds = NormalizeCategoryIds(values, 0); @@ -264,6 +367,22 @@ return; } + var resolvedAiModel = ResolveSelectedAiModel(); + if (enableAiVerification) + { + if (verificationTimeoutSeconds < 3 || verificationTimeoutSeconds > 300) + { + await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "验证等待超时范围应为 3-300 秒")); + return; + } + + if (IsCustomAiModelSelected && string.IsNullOrWhiteSpace(resolvedAiModel)) + { + await DraftChanged.InvokeAsync(new ModuleTaskDraft(0, null, false, "请选择有效的自定义模型名")); + return; + } + } + var categoryNames = selectedCategories.Select(x => x.Name).ToList(); var cfg = new UserChatActiveTaskConfig { @@ -278,7 +397,10 @@ MaxMessages = maxMessages, AccountMode = accountMode, TargetMode = targetMode, - MessageMode = messageMode + MessageMode = messageMode, + EnableAiVerification = enableAiVerification, + AiModel = resolvedAiModel, + VerificationTimeoutSeconds = enableAiVerification ? verificationTimeoutSeconds : 15 }; var total = maxMessages > 0 ? maxMessages : 0; diff --git a/src/TelegramPanel.Web/Components/Pages/Settings.razor b/src/TelegramPanel.Web/Components/Pages/Settings.razor index a50e46a..2d900ae 100644 --- a/src/TelegramPanel.Web/Components/Pages/Settings.razor +++ b/src/TelegramPanel.Web/Components/Pages/Settings.razor @@ -10,7 +10,7 @@ @inject BatchTaskManagementService TaskManagement @inject ISnackbar Snackbar @inject IDialogService DialogService -@inject IWebHostEnvironment Environment +@inject IWebHostEnvironment HostEnvironment @inject PanelTimeZoneService TimeZone @inject NavigationManager Navigation @inject CloudMailClient CloudMail @@ -81,6 +81,30 @@ + + + + AI 设置 + + + + + + + + + + 保存 AI 配置 + + + @@ -286,6 +310,10 @@ private string cloudMailAdminEmail = ""; private string cloudMailAdminPassword = ""; private bool cloudMailGeneratingToken = false; + private string aiEndpoint = ""; + private string aiApiKey = ""; + private string aiDefaultModel = ""; + private string aiPresetModelsText = ""; private sealed record TimeZoneChoice(string Id, string Label); @@ -308,7 +336,7 @@ } } - private string LocalConfigPath => LocalConfigFile.ResolvePath(Configuration, Environment); + private string LocalConfigPath => LocalConfigFile.ResolvePath(Configuration, HostEnvironment); private bool LocalConfigExists => System.IO.File.Exists(LocalConfigPath); protected override void OnInitialized() @@ -333,6 +361,10 @@ cloudMailBaseUrl = Configuration["CloudMail:BaseUrl"] ?? ""; cloudMailDomain = Configuration["CloudMail:Domain"] ?? ""; cloudMailToken = Configuration["CloudMail:Token"] ?? ""; + aiEndpoint = Configuration["AI:OpenAI:Endpoint"] ?? ""; + aiApiKey = Configuration["AI:OpenAI:ApiKey"] ?? ""; + aiDefaultModel = Configuration["AI:OpenAI:DefaultModel"] ?? ""; + aiPresetModelsText = string.Join(System.Environment.NewLine, Configuration.GetSection("AI:OpenAI:PresetModels").GetChildren().Select(x => x.Value).Where(x => !string.IsNullOrWhiteSpace(x))); } private async Task SaveApiConfig() @@ -432,6 +464,44 @@ } } + private async Task SaveAiConfig() + { + try + { + var normalizedEndpoint = AiOpenAiSettingsSnapshot.NormalizeEndpoint(aiEndpoint); + var normalizedApiKey = AiOpenAiSettingsSnapshot.NormalizeApiKey(aiApiKey); + var normalizedDefaultModel = AiOpenAiSettingsSnapshot.NormalizeModel(aiDefaultModel); + var presetModels = AiOpenAiSettingsSnapshot.NormalizeModelEntries(ParseLines(aiPresetModelsText), normalizedDefaultModel); + + var root = await LoadOrCreateLocalConfigAsync(); + var ai = EnsureObject(root, "AI"); + var openAi = EnsureObject(ai, "OpenAI"); + + openAi["Endpoint"] = normalizedEndpoint ?? string.Empty; + openAi["ApiKey"] = normalizedApiKey ?? string.Empty; + openAi["DefaultModel"] = normalizedDefaultModel ?? string.Empty; + + var modelsArray = new System.Text.Json.Nodes.JsonArray(); + foreach (var model in presetModels) + modelsArray.Add(model); + openAi["PresetModels"] = modelsArray; + + await SaveLocalConfigAsync(root); + + aiEndpoint = normalizedEndpoint ?? string.Empty; + aiApiKey = normalizedApiKey ?? string.Empty; + aiDefaultModel = normalizedDefaultModel ?? string.Empty; + aiPresetModelsText = string.Join(System.Environment.NewLine, presetModels); + + Snackbar.Add("AI 配置已保存(配置热重载后生效)", Severity.Success); + StateHasChanged(); + } + catch (Exception ex) + { + Snackbar.Add($"保存失败:{ex.Message}(路径:{LocalConfigPath})", Severity.Error); + } + } + private async Task GenerateCloudMailToken() { if (cloudMailGeneratingToken) @@ -552,6 +622,18 @@ return created; } + private static List ParseLines(string? text) + { + if (string.IsNullOrWhiteSpace(text)) + return new List(); + + return text + .Split(new[] { "\r\n", "\n", "\r" }, StringSplitOptions.RemoveEmptyEntries) + .Select(x => (x ?? string.Empty).Trim()) + .Where(x => x.Length > 0) + .ToList(); + } + private async Task SaveBatchConfig() { if (defaultDelay < 1000 || defaultDelay > 10000) diff --git a/src/TelegramPanel.Web/Program.cs b/src/TelegramPanel.Web/Program.cs index 983267e..85345c6 100644 --- a/src/TelegramPanel.Web/Program.cs +++ b/src/TelegramPanel.Web/Program.cs @@ -416,10 +416,14 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.Configure(builder.Configuration.GetSection("UpdateCheck")); builder.Services.Configure(builder.Configuration.GetSection("SelfUpdate")); +builder.Services.Configure(builder.Configuration.GetSection("AI:OpenAI")); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.Configure(builder.Configuration.GetSection("System")); builder.Services.AddSingleton(); +builder.Services.AddScoped(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(sp => sp.GetRequiredService()); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -432,6 +436,7 @@ builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHttpClient(); builder.Services.AddHttpClient(); +builder.Services.AddHttpClient(nameof(TelegramPanelAiService)); builder.Services.AddScoped(); builder.Services.AddModuleSystem(builder.Configuration, builder.Environment); builder.Services.AddSingleton(); diff --git a/src/TelegramPanel.Web/Services/AiOpenAiOptions.cs b/src/TelegramPanel.Web/Services/AiOpenAiOptions.cs new file mode 100644 index 0000000..48f2954 --- /dev/null +++ b/src/TelegramPanel.Web/Services/AiOpenAiOptions.cs @@ -0,0 +1,187 @@ +namespace TelegramPanel.Web.Services; + +public sealed class AiOpenAiOptions +{ + public string? Endpoint { get; set; } + + public string? ApiKey { get; set; } + + public string? DefaultModel { get; set; } + + public List PresetModels { get; set; } = new(); + + public AiOpenAiSettingsSnapshot ToSnapshot() + { + return AiOpenAiSettingsSnapshot.From(this); + } +} + +public sealed record AiOpenAiSettingsSnapshot( + string? Endpoint, + string? ApiKey, + string? DefaultModel, + IReadOnlyList PresetModels) +{ + public static AiOpenAiSettingsSnapshot Empty { get; } = new(null, null, null, Array.Empty()); + + public string CompletionsPath => "/chat/completions"; + + public bool HasEndpoint => !string.IsNullOrWhiteSpace(Endpoint); + + public bool HasApiKey => !string.IsNullOrWhiteSpace(ApiKey); + + public bool IsConfigured => HasEndpoint && HasApiKey; + + public string? ResolveModel(string? overrideModel) + { + var preferred = NormalizeModel(overrideModel); + if (!string.IsNullOrWhiteSpace(preferred)) + return preferred; + + return NormalizeModel(DefaultModel); + } + + public string? GetCompletionsEndpoint() + { + if (!HasEndpoint) + return null; + + return $"{Endpoint!.TrimEnd('/')}{CompletionsPath}"; + } + + public IReadOnlyList GetSelectableModels() + { + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + var list = new List(); + + var defaultModel = NormalizeModel(DefaultModel); + if (!string.IsNullOrWhiteSpace(defaultModel) && seen.Add(defaultModel)) + list.Add(defaultModel); + + foreach (var item in PresetModels) + { + var normalized = NormalizeModel(item); + if (string.IsNullOrWhiteSpace(normalized)) + continue; + + if (seen.Add(normalized)) + list.Add(normalized); + } + + return list; + } + + public bool TryValidateForTask(string? overrideModel, out string? error) + { + if (!HasEndpoint) + { + error = "未配置 AI 端点(AI:OpenAI:Endpoint)"; + return false; + } + + var completionsEndpoint = GetCompletionsEndpoint(); + if (string.IsNullOrWhiteSpace(completionsEndpoint) + || !Uri.TryCreate(completionsEndpoint, UriKind.Absolute, out var endpointUri) + || (endpointUri.Scheme != Uri.UriSchemeHttp && endpointUri.Scheme != Uri.UriSchemeHttps)) + { + error = "AI 端点格式无效,请填写如 https://xxx.com/v1"; + return false; + } + + if (!HasApiKey) + { + error = "未配置 AI Key(AI:OpenAI:ApiKey)"; + return false; + } + + if (string.IsNullOrWhiteSpace(ResolveModel(overrideModel))) + { + error = "未配置 AI 模型:请在系统设置填写默认模型,或在任务里选择自定义模型"; + return false; + } + + error = null; + return true; + } + + public static AiOpenAiSettingsSnapshot From(AiOpenAiOptions? options) + { + if (options == null) + return Empty; + + var defaultModel = NormalizeModel(options.DefaultModel); + var presetModels = NormalizeModelEntries(options.PresetModels, defaultModel); + return new AiOpenAiSettingsSnapshot( + NormalizeEndpoint(options.Endpoint), + NormalizeApiKey(options.ApiKey), + defaultModel, + presetModels); + } + + public static string? NormalizeEndpoint(string? value) + { + var endpoint = (value ?? string.Empty).Trim(); + if (endpoint.Length == 0) + return null; + + if (!endpoint.Contains("://", StringComparison.Ordinal)) + endpoint = $"https://{endpoint.TrimStart('/')}"; + + endpoint = endpoint.TrimEnd('/'); + + var suffixes = new[] + { + "/chat/completions", + "/responses", + "/completions" + }; + + foreach (var suffix in suffixes) + { + if (endpoint.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)) + { + endpoint = endpoint[..^suffix.Length].TrimEnd('/'); + break; + } + } + + return endpoint.Length == 0 ? null : endpoint; + } + + public static string? NormalizeApiKey(string? value) + { + var apiKey = (value ?? string.Empty).Trim(); + return apiKey.Length == 0 ? null : apiKey; + } + + public static string? NormalizeModel(string? value) + { + var model = (value ?? string.Empty).Trim(); + return model.Length == 0 ? null : model; + } + + public static List NormalizeModelEntries(IEnumerable? values, string? defaultModel = null) + { + var normalizedDefault = NormalizeModel(defaultModel); + var result = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var item in values ?? Array.Empty()) + { + var normalized = NormalizeModel(item); + if (string.IsNullOrWhiteSpace(normalized)) + continue; + + if (!string.IsNullOrWhiteSpace(normalizedDefault) + && string.Equals(normalized, normalizedDefault, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (seen.Add(normalized)) + result.Add(normalized); + } + + return result; + } +} diff --git a/src/TelegramPanel.Web/Services/TelegramPanelAiService.cs b/src/TelegramPanel.Web/Services/TelegramPanelAiService.cs new file mode 100644 index 0000000..f39ffe1 --- /dev/null +++ b/src/TelegramPanel.Web/Services/TelegramPanelAiService.cs @@ -0,0 +1,263 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Options; +using TelegramPanel.Modules; + +namespace TelegramPanel.Web.Services; + +public sealed class TelegramPanelAiService : ITelegramPanelAiService +{ + private readonly IHttpClientFactory _httpClientFactory; + private readonly IOptionsMonitor _optionsMonitor; + private readonly ILogger _logger; + + public TelegramPanelAiService( + IHttpClientFactory httpClientFactory, + IOptionsMonitor optionsMonitor, + ILogger logger) + { + _httpClientFactory = httpClientFactory; + _optionsMonitor = optionsMonitor; + _logger = logger; + } + + public async Task ChooseActionAsync( + TelegramPanelAiChooseActionRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + var settings = _optionsMonitor.CurrentValue.ToSnapshot(); + if (!settings.TryValidateForTask(request.Model, out var validateError)) + { + return new TelegramPanelAiChooseActionResult( + Success: false, + Mode: null, + ButtonIndex: null, + ReplyText: null, + Reason: null, + Error: validateError); + } + + var model = settings.ResolveModel(request.Model)!; + var buttons = request.Buttons ?? Array.Empty(); + var buttonLines = buttons.Select(x => $"{x.Index}. {x.Text}").ToArray(); + var systemPrompt = + "你是 Telegram 验证助手。请根据消息文本、按钮列表和可选图片,输出唯一 JSON 决策。" + + "只允许输出 JSON,不要输出 Markdown,不要解释。JSON 格式:" + + "{\"mode\":\"click_button\"|\"reply_text\",\"button_index\":0,\"reply_text\":null,\"reason\":\"30字内\"}。" + + "如果需要点击按钮,mode=click_button,button_index 必须是 0 基索引;如果更适合直接回答,mode=reply_text,reply_text 必须填写最终回复内容。"; + + var text = new StringBuilder(); + text.AppendLine("请处理下面这条 Telegram 验证消息。"); + if (!string.IsNullOrWhiteSpace(request.Context)) + text.AppendLine($"上下文:{request.Context.Trim()}"); + text.AppendLine($"消息文本:{(string.IsNullOrWhiteSpace(request.MessageText) ? "(空)" : request.MessageText.Trim())}"); + text.AppendLine(buttonLines.Length == 0 + ? "按钮列表:无" + : $"按钮列表:{string.Join(";", buttonLines)}"); + text.AppendLine("要求:优先输出最可靠的结果;如果图片信息重要,请结合图片判断。只输出 JSON。 "); + + object userContent = request.Image is { Content.Length: > 0 } + ? new object[] + { + new { type = "text", text = text.ToString() }, + new + { + type = "image_url", + image_url = new + { + url = $"data:{request.Image.MimeType};base64,{Convert.ToBase64String(request.Image.Content)}" + } + } + } + : text.ToString(); + + try + { + var content = await SendChatCompletionAsync(settings, model, systemPrompt, userContent, cancellationToken); + var json = ExtractJsonObject(content); + using var document = JsonDocument.Parse(json); + var root = document.RootElement; + var mode = root.TryGetProperty("mode", out var modeElement) + ? (modeElement.GetString() ?? string.Empty).Trim() + : string.Empty; + var reason = root.TryGetProperty("reason", out var reasonElement) + ? reasonElement.GetString() + : null; + + if (string.Equals(mode, "click_button", StringComparison.OrdinalIgnoreCase)) + { + if (!root.TryGetProperty("button_index", out var buttonIndexElement) + || buttonIndexElement.ValueKind != JsonValueKind.Number + || !buttonIndexElement.TryGetInt32(out var buttonIndex)) + { + return new TelegramPanelAiChooseActionResult(false, null, null, null, null, "AI 返回了点击按钮模式,但缺少有效的 button_index"); + } + + return new TelegramPanelAiChooseActionResult(true, "click_button", buttonIndex, null, reason, null); + } + + if (string.Equals(mode, "reply_text", StringComparison.OrdinalIgnoreCase)) + { + var replyText = root.TryGetProperty("reply_text", out var replyElement) + ? (replyElement.GetString() ?? string.Empty).Trim() + : string.Empty; + + if (replyText.Length == 0) + return new TelegramPanelAiChooseActionResult(false, null, null, null, null, "AI 返回了 reply_text 模式,但 reply_text 为空"); + + return new TelegramPanelAiChooseActionResult(true, "reply_text", null, replyText, reason, null); + } + + return new TelegramPanelAiChooseActionResult(false, null, null, null, null, $"AI 返回了未知模式:{mode}"); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to parse AI choice response"); + return new TelegramPanelAiChooseActionResult(false, null, null, null, null, $"AI 响应解析失败:{ex.Message}"); + } + } + + public async Task ReplyTextAsync( + TelegramPanelAiReplyTextRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + var settings = _optionsMonitor.CurrentValue.ToSnapshot(); + if (!settings.TryValidateForTask(request.Model, out var validateError)) + return new TelegramPanelAiReplyTextResult(false, null, validateError); + + var model = settings.ResolveModel(request.Model)!; + var systemPrompt = (request.Prompt ?? string.Empty).Trim(); + if (systemPrompt.Length == 0) + systemPrompt = "你是 Telegram 验证/答题助手。请只回复最终答案,不要解释,不要输出多余内容。"; + + var text = new StringBuilder(); + text.AppendLine((request.Query ?? string.Empty).Trim()); + if (!string.IsNullOrWhiteSpace(request.Context)) + text.AppendLine($"上下文:{request.Context.Trim()}"); + text.AppendLine("只回复最终答案。不要解释。不要添加引号。 "); + + object userContent = request.Image is { Content.Length: > 0 } + ? new object[] + { + new { type = "text", text = text.ToString() }, + new + { + type = "image_url", + image_url = new + { + url = $"data:{request.Image.MimeType};base64,{Convert.ToBase64String(request.Image.Content)}" + } + } + } + : text.ToString(); + + try + { + var content = await SendChatCompletionAsync(settings, model, systemPrompt, userContent, cancellationToken); + var normalized = StripMarkdownFence(content).Trim(); + if (normalized.Length == 0) + return new TelegramPanelAiReplyTextResult(false, null, "AI 返回了空回复"); + + return new TelegramPanelAiReplyTextResult(true, normalized, null); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to generate AI reply text"); + return new TelegramPanelAiReplyTextResult(false, null, $"AI 请求失败:{ex.Message}"); + } + } + + private async Task SendChatCompletionAsync( + AiOpenAiSettingsSnapshot settings, + string model, + string systemPrompt, + object userContent, + CancellationToken cancellationToken) + { + var endpoint = settings.GetCompletionsEndpoint() + ?? throw new InvalidOperationException("OpenAI 端点未配置"); + var apiKey = settings.ApiKey + ?? throw new InvalidOperationException("OpenAI Key 未配置"); + + var client = _httpClientFactory.CreateClient(nameof(TelegramPanelAiService)); + using var request = new HttpRequestMessage(HttpMethod.Post, endpoint); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); + request.Content = JsonContent.Create(new + { + model, + temperature = 0.1, + stream = false, + messages = new object[] + { + new { role = "system", content = systemPrompt }, + new { role = "user", content = userContent } + } + }); + + using var response = await client.SendAsync(request, cancellationToken); + var body = await response.Content.ReadAsStringAsync(cancellationToken); + if (!response.IsSuccessStatusCode) + throw new InvalidOperationException($"AI 请求失败(HTTP {(int)response.StatusCode}):{TrimForError(body)}"); + + try + { + using var document = JsonDocument.Parse(body); + var contentElement = document.RootElement + .GetProperty("choices")[0] + .GetProperty("message") + .GetProperty("content"); + + return contentElement.ValueKind switch + { + JsonValueKind.String => contentElement.GetString() ?? string.Empty, + JsonValueKind.Array => string.Concat(contentElement.EnumerateArray() + .Where(x => x.TryGetProperty("type", out var type) && string.Equals(type.GetString(), "text", StringComparison.OrdinalIgnoreCase)) + .Select(x => x.TryGetProperty("text", out var text) ? text.GetString() ?? string.Empty : string.Empty)), + _ => throw new InvalidOperationException("AI 返回的 content 结构不受支持") + }; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to parse AI completion response: {Body}", body); + throw new InvalidOperationException($"AI 响应结构无效:{ex.Message}"); + } + } + + private static string ExtractJsonObject(string content) + { + var normalized = StripMarkdownFence(content).Trim(); + var start = normalized.IndexOf('{'); + var end = normalized.LastIndexOf('}'); + if (start < 0 || end <= start) + throw new InvalidOperationException("AI 未返回 JSON 对象"); + + return normalized[start..(end + 1)]; + } + + private static string StripMarkdownFence(string content) + { + var text = (content ?? string.Empty).Trim(); + if (!text.StartsWith("```", StringComparison.Ordinal)) + return text; + + var firstLineEnd = text.IndexOf('\n'); + if (firstLineEnd < 0) + return text.Trim('`').Trim(); + + var body = text[(firstLineEnd + 1)..]; + var lastFence = body.LastIndexOf("```", StringComparison.Ordinal); + return lastFence >= 0 ? body[..lastFence].Trim() : body.Trim(); + } + + private static string TrimForError(string text) + { + var normalized = (text ?? string.Empty).Replace("\r", " ").Replace("\n", " ").Trim(); + return normalized.Length <= 400 ? normalized : normalized[..400]; + } +} diff --git a/src/TelegramPanel.Web/Services/UserChatActiveAiVerificationService.cs b/src/TelegramPanel.Web/Services/UserChatActiveAiVerificationService.cs new file mode 100644 index 0000000..3723445 --- /dev/null +++ b/src/TelegramPanel.Web/Services/UserChatActiveAiVerificationService.cs @@ -0,0 +1,137 @@ +using TelegramPanel.Core.Models; +using TelegramPanel.Core.Services.Telegram; +using TelegramPanel.Data.Entities; +using TelegramPanel.Modules; + +namespace TelegramPanel.Web.Services; + +public sealed class UserChatActiveAiVerificationService +{ + private readonly AccountTelegramToolsService _accountTools; + private readonly ITelegramPanelAiService _aiService; + private readonly ILogger _logger; + + public UserChatActiveAiVerificationService( + AccountTelegramToolsService accountTools, + ITelegramPanelAiService aiService, + ILogger logger) + { + _accountTools = accountTools; + _aiService = aiService; + _logger = logger; + } + + public async Task<(bool Success, string? Error, string? ActionSummary)> TryHandleAsync( + Account account, + AccountTelegramToolsService.ResolvedChatTarget target, + int sentMessageId, + UserChatActiveTaskConfig config, + CancellationToken cancellationToken) + { + var wait = await _accountTools.WaitForBotVerificationMessageAsync( + account.Id, + target, + sentMessageId, + account.Username, + config.VerificationTimeoutSeconds, + cancellationToken); + + if (!wait.Success || wait.Candidate == null) + return (false, wait.Error, null); + + var candidate = wait.Candidate; + var image = candidate.ImageJpegBytes is { Length: > 0 } + ? new TelegramPanelAiImageInput(candidate.ImageJpegBytes) + : null; + + if (candidate.Buttons.Count > 0) + { + var decision = await _aiService.ChooseActionAsync( + new TelegramPanelAiChooseActionRequest( + Model: config.AiModel, + MessageText: candidate.Text, + Buttons: candidate.Buttons.Select(x => new TelegramPanelAiButtonOption(x.Index, x.Text)).ToList(), + Image: image, + Context: "这是 Telegram 群里的验证消息,请根据按钮文案、题目文本和图片决定最合适动作。"), + cancellationToken); + + if (!decision.Success) + return (false, decision.Error, null); + + if (string.Equals(decision.Mode, "reply_text", StringComparison.OrdinalIgnoreCase)) + { + var replyText = (decision.ReplyText ?? string.Empty).Trim(); + if (replyText.Length == 0) + return (false, "AI 返回了 reply_text,但内容为空", null); + + var reply = await _accountTools.SendMessageToResolvedChatAsync( + account.Id, + target, + replyText, + replyToMessageId: candidate.MessageId, + cancellationToken: cancellationToken); + + if (!reply.Success) + return (false, reply.Error, null); + + return (true, null, $"AI 文本回复:{replyText}"); + } + + var button = candidate.Buttons.FirstOrDefault(x => x.Index == decision.ButtonIndex); + if (button == null) + return (false, $"AI 返回的按钮索引无效:{decision.ButtonIndex}", null); + + var click = await _accountTools.ClickInlineButtonAsync( + account.Id, + target, + candidate.MessageId, + button.CallbackData, + cancellationToken); + + if (!click.Success) + return (false, click.Error, null); + + _logger.LogInformation( + "AI verification button clicked for account {AccountId}, chat {ChatId}, message {MessageId}", + account.Id, + target.CanonicalId, + candidate.MessageId); + + return (true, null, $"AI 点击按钮:{button.Text}"); + } + + var replyDecision = await _aiService.ReplyTextAsync( + new TelegramPanelAiReplyTextRequest( + Model: config.AiModel, + Prompt: "这是 Telegram 群里的验证消息,请直接给出最终答案文本。", + Query: candidate.Text ?? string.Empty, + Image: image, + Context: "只返回最终答案,不要解释。"), + cancellationToken); + + if (!replyDecision.Success) + return (false, replyDecision.Error, null); + + var replyContent = (replyDecision.ReplyText ?? string.Empty).Trim(); + if (replyContent.Length == 0) + return (false, "AI 返回答案为空", null); + + var sendReply = await _accountTools.SendMessageToResolvedChatAsync( + account.Id, + target, + replyContent, + replyToMessageId: candidate.MessageId, + cancellationToken: cancellationToken); + + if (!sendReply.Success) + return (false, sendReply.Error, null); + + _logger.LogInformation( + "AI verification reply sent for account {AccountId}, chat {ChatId}, message {MessageId}", + account.Id, + target.CanonicalId, + candidate.MessageId); + + return (true, null, $"AI 文本回复:{replyContent}"); + } +} diff --git a/src/TelegramPanel.Web/Services/UserChatActiveTaskHandler.cs b/src/TelegramPanel.Web/Services/UserChatActiveTaskHandler.cs index ca976a8..adbad1b 100644 --- a/src/TelegramPanel.Web/Services/UserChatActiveTaskHandler.cs +++ b/src/TelegramPanel.Web/Services/UserChatActiveTaskHandler.cs @@ -1,6 +1,7 @@ using System.Text.Json; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using TelegramPanel.Core.BatchTasks; using TelegramPanel.Core.Services; using TelegramPanel.Core.Services.Telegram; @@ -22,12 +23,21 @@ public sealed class UserChatActiveTaskHandler : IModuleTaskHandler var accountManagement = host.Services.GetRequiredService(); var accountTools = host.Services.GetRequiredService(); var templateRendering = host.Services.GetRequiredService(); + var aiVerification = host.Services.GetRequiredService(); + var aiOptions = host.Services.GetRequiredService>(); var config = DeserializeConfig(host.Config); ValidateAndNormalizeConfig(config); config.Canceled = false; config.Error = null; + if (config.EnableAiVerification) + { + var settings = aiOptions.CurrentValue.ToSnapshot(); + if (!settings.TryValidateForTask(config.AiModel, out var aiError)) + throw new InvalidOperationException($"AI 验证已启用,但全局 AI 配置无效:{aiError}"); + } + var selectedCategoryIds = NormalizeSelectedCategoryIds(config).ToHashSet(); var allAccounts = (await accountManagement.GetAllAccountsAsync()) .Where(x => x.IsActive && x.UserId > 0 && x.Category?.ExcludeFromOperations != true) @@ -188,7 +198,7 @@ public sealed class UserChatActiveTaskHandler : IModuleTaskHandler accountSlot.Account.Id, targetSlot.Resolved, text, - cancellationToken); + cancellationToken: cancellationToken); completed++; var hadFailureThisRound = false; @@ -212,6 +222,42 @@ public sealed class UserChatActiveTaskHandler : IModuleTaskHandler await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(config)); } + else if (config.EnableAiVerification) + { + if (!send.MessageId.HasValue || send.MessageId.Value <= 0) + { + failed++; + hadFailureThisRound = true; + AddFailure(config, accountSlot.Account, targetSlot.RawTarget, "消息已发送,但未获取到消息 ID,无法执行 AI 验证"); + await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(config)); + } + else + { + var verification = await aiVerification.TryHandleAsync( + accountSlot.Account, + targetSlot.Resolved, + send.MessageId.Value, + config, + cancellationToken); + + if (!verification.Success) + { + failed++; + hadFailureThisRound = true; + AddFailure(config, accountSlot.Account, targetSlot.RawTarget, NormalizeReason(verification.Error)); + await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(config)); + } + else + { + logger.LogInformation( + "UserChatActive AI verification completed: taskId={TaskId}, accountId={AccountId}, target={Target}, action={Action}", + host.TaskId, + accountSlot.Account.Id, + targetSlot.RawTarget, + verification.ActionSummary ?? "(none)"); + } + } + } if (ShouldPersistProgress(completed, hadFailureThisRound, lastProgressPersistAt)) { @@ -300,6 +346,10 @@ public sealed class UserChatActiveTaskHandler : IModuleTaskHandler if (config.DelayMaxMs < config.DelayMinMs) config.DelayMaxMs = config.DelayMinMs; if (config.MaxMessages < 0) config.MaxMessages = 0; + if (config.VerificationTimeoutSeconds < 3) config.VerificationTimeoutSeconds = 15; + if (config.VerificationTimeoutSeconds > 300) config.VerificationTimeoutSeconds = 300; + + config.AiModel = AiOpenAiSettingsSnapshot.NormalizeModel(config.AiModel); config.AccountMode = NormalizeMode(config.AccountMode); config.TargetMode = NormalizeMode(config.TargetMode); diff --git a/src/TelegramPanel.Web/Services/UserChatActiveTaskModels.cs b/src/TelegramPanel.Web/Services/UserChatActiveTaskModels.cs index 7b7744a..08bb6b3 100644 --- a/src/TelegramPanel.Web/Services/UserChatActiveTaskModels.cs +++ b/src/TelegramPanel.Web/Services/UserChatActiveTaskModels.cs @@ -46,6 +46,15 @@ public sealed class UserChatActiveTaskConfig [JsonPropertyName("max_messages")] public int MaxMessages { get; set; } + [JsonPropertyName("enable_ai_verification")] + public bool EnableAiVerification { get; set; } + + [JsonPropertyName("ai_model")] + public string? AiModel { get; set; } + + [JsonPropertyName("verification_timeout_seconds")] + public int VerificationTimeoutSeconds { get; set; } = 15; + [JsonPropertyName("canceled")] public bool Canceled { get; set; } diff --git a/src/TelegramPanel.Web/Services/UserChatActiveTaskRerunBuilder.cs b/src/TelegramPanel.Web/Services/UserChatActiveTaskRerunBuilder.cs index ade6479..87a4d7e 100644 --- a/src/TelegramPanel.Web/Services/UserChatActiveTaskRerunBuilder.cs +++ b/src/TelegramPanel.Web/Services/UserChatActiveTaskRerunBuilder.cs @@ -71,6 +71,10 @@ public sealed class UserChatActiveTaskRerunBuilder : IModuleTaskRerunBuilder if (cfg.DelayMaxMs > 600000) cfg.DelayMaxMs = 600000; if (cfg.DelayMaxMs < cfg.DelayMinMs) cfg.DelayMaxMs = cfg.DelayMinMs; if (cfg.MaxMessages < 0) cfg.MaxMessages = 0; + if (cfg.VerificationTimeoutSeconds < 3) cfg.VerificationTimeoutSeconds = 15; + if (cfg.VerificationTimeoutSeconds > 300) cfg.VerificationTimeoutSeconds = 300; + + cfg.AiModel = AiOpenAiSettingsSnapshot.NormalizeModel(cfg.AiModel); cfg.AccountMode = NormalizeModeValue(cfg.AccountMode); cfg.TargetMode = NormalizeModeValue(cfg.TargetMode); From 322a1bb164a836467c674b86f42ef425a1f3ccb9 Mon Sep 17 00:00:00 2001 From: meoacgx Date: Tue, 10 Mar 2026 04:14:27 +0800 Subject: [PATCH 09/13] fix: add AI connectivity test and config save support --- .../Dialogs/EditGenericApiDialog.razor | 2 +- .../Components/Pages/ApiCenter.razor | 4 +- .../Components/Pages/Settings.razor | 72 ++++++++++++++++++- .../AccountDataAutoSyncBackgroundService.cs | 2 +- .../Services/LocalConfigFile.cs | 17 +++++ .../Services/TaskAssetScopeHelper.cs | 4 +- .../Services/TelegramPanelAiService.cs | 38 ++++++++++ 7 files changed, 132 insertions(+), 7 deletions(-) diff --git a/src/TelegramPanel.Web/Components/Dialogs/EditGenericApiDialog.razor b/src/TelegramPanel.Web/Components/Dialogs/EditGenericApiDialog.razor index b061a6b..2743a9b 100644 --- a/src/TelegramPanel.Web/Components/Dialogs/EditGenericApiDialog.razor +++ b/src/TelegramPanel.Web/Components/Dialogs/EditGenericApiDialog.razor @@ -57,7 +57,7 @@ { model = Clone(Api); var cfg = model.Config ?? new JsonObject(); - configJson = cfg.ToJsonString(new JsonSerializerOptions { WriteIndented = true }); + configJson = TelegramPanel.Web.Services.LocalConfigFile.ToIndentedJson(cfg); } private void Cancel() => MudDialog.Cancel(); diff --git a/src/TelegramPanel.Web/Components/Pages/ApiCenter.razor b/src/TelegramPanel.Web/Components/Pages/ApiCenter.razor index 92dc19e..2df5e6a 100644 --- a/src/TelegramPanel.Web/Components/Pages/ApiCenter.razor +++ b/src/TelegramPanel.Web/Components/Pages/ApiCenter.razor @@ -311,7 +311,7 @@ var root = await LoadOrCreateLocalConfigAsync(); var external = EnsureObject(root, "ExternalApi"); - var options = new JsonSerializerOptions { WriteIndented = true }; + var options = LocalConfigFile.CreateIndentedJsonSerializerOptions(); external["Apis"] = JsonSerializer.SerializeToNode(list, options) ?? new JsonArray(); await SaveLocalConfigAsync(root); @@ -333,7 +333,7 @@ private async Task SaveLocalConfigAsync(JsonObject root) { - var json = root.ToJsonString(new JsonSerializerOptions { WriteIndented = true }); + var json = LocalConfigFile.ToIndentedJson(root); var path = LocalConfigPath; await LocalConfigFile.EnsureExistsAsync(path); await LocalConfigFile.WriteJsonAtomicallyAsync(path, json); diff --git a/src/TelegramPanel.Web/Components/Pages/Settings.razor b/src/TelegramPanel.Web/Components/Pages/Settings.razor index 2d900ae..2832312 100644 --- a/src/TelegramPanel.Web/Components/Pages/Settings.razor +++ b/src/TelegramPanel.Web/Components/Pages/Settings.razor @@ -14,6 +14,7 @@ @inject PanelTimeZoneService TimeZone @inject NavigationManager Navigation @inject CloudMailClient CloudMail +@inject TelegramPanelAiService PanelAiService @using TelegramPanel.Web.Services @using TelegramPanel.Core.Utils @@ -97,12 +98,28 @@ + + 测试连通会直接使用当前表单里的端点、Key 和模型,不必先保存。 + 保存 AI 配置 + + @if (aiTesting) + { + + 测试中... + } + else + { + 测试连通 + } + @@ -314,6 +331,7 @@ private string aiApiKey = ""; private string aiDefaultModel = ""; private string aiPresetModelsText = ""; + private bool aiTesting = false; private sealed record TimeZoneChoice(string Id, string Label); @@ -502,6 +520,58 @@ } } + private async Task TestAiConnectionAsync() + { + if (aiTesting) + return; + + aiTesting = true; + try + { + var normalizedEndpoint = AiOpenAiSettingsSnapshot.NormalizeEndpoint(aiEndpoint); + var normalizedApiKey = AiOpenAiSettingsSnapshot.NormalizeApiKey(aiApiKey); + var normalizedDefaultModel = AiOpenAiSettingsSnapshot.NormalizeModel(aiDefaultModel); + var presetModels = AiOpenAiSettingsSnapshot.NormalizeModelEntries(ParseLines(aiPresetModelsText), normalizedDefaultModel); + var snapshot = new AiOpenAiSettingsSnapshot(normalizedEndpoint, normalizedApiKey, normalizedDefaultModel, presetModels); + var testModel = ResolveAiTestModel(snapshot); + + if (!snapshot.TryValidateForTask(testModel, out var validateError)) + { + Snackbar.Add($"AI 连通测试失败:{validateError}", Severity.Warning); + return; + } + + var result = await PanelAiService.TestConnectionAsync(snapshot, testModel, CancellationToken.None); + if (!result.Success) + { + Snackbar.Add($"AI 连通测试失败:{result.Error}", Severity.Error); + return; + } + + var modelName = result.Model ?? snapshot.ResolveModel(testModel) ?? "(未知模型)"; + var preview = string.IsNullOrWhiteSpace(result.ResponseText) ? "(空响应)" : result.ResponseText; + Snackbar.Add($"AI 连通成功:模型 {modelName},返回 {preview}", Severity.Success); + } + catch (Exception ex) + { + Snackbar.Add($"AI 连通测试失败:{ex.Message}", Severity.Error); + } + finally + { + aiTesting = false; + StateHasChanged(); + } + } + + private string? ResolveAiTestModel(AiOpenAiSettingsSnapshot snapshot) + { + var defaultModel = AiOpenAiSettingsSnapshot.NormalizeModel(aiDefaultModel); + if (!string.IsNullOrWhiteSpace(defaultModel)) + return defaultModel; + + return snapshot.PresetModels.FirstOrDefault(x => !string.IsNullOrWhiteSpace(x)); + } + private async Task GenerateCloudMailToken() { if (cloudMailGeneratingToken) @@ -606,7 +676,7 @@ private async Task SaveLocalConfigAsync(System.Text.Json.Nodes.JsonObject root) { - var json = root.ToJsonString(new System.Text.Json.JsonSerializerOptions { WriteIndented = true }); + var json = LocalConfigFile.ToIndentedJson(root); var path = LocalConfigPath; await LocalConfigFile.EnsureExistsAsync(path); await LocalConfigFile.WriteJsonAtomicallyAsync(path, json); diff --git a/src/TelegramPanel.Web/Services/AccountDataAutoSyncBackgroundService.cs b/src/TelegramPanel.Web/Services/AccountDataAutoSyncBackgroundService.cs index 2d37694..2729587 100644 --- a/src/TelegramPanel.Web/Services/AccountDataAutoSyncBackgroundService.cs +++ b/src/TelegramPanel.Web/Services/AccountDataAutoSyncBackgroundService.cs @@ -193,7 +193,7 @@ public sealed class AccountDataAutoSyncBackgroundService : BackgroundService var sync = EnsureObject(root, "Sync"); sync["LastAutoSyncAtUtc"] = utcNow.ToString("O", CultureInfo.InvariantCulture); - var updatedJson = root.ToJsonString(new System.Text.Json.JsonSerializerOptions { WriteIndented = true }); + var updatedJson = LocalConfigFile.ToIndentedJson(root); await LocalConfigFile.WriteJsonAtomicallyAsync(path, updatedJson, cancellationToken); } catch (Exception ex) diff --git a/src/TelegramPanel.Web/Services/LocalConfigFile.cs b/src/TelegramPanel.Web/Services/LocalConfigFile.cs index 412f153..97afc39 100644 --- a/src/TelegramPanel.Web/Services/LocalConfigFile.cs +++ b/src/TelegramPanel.Web/Services/LocalConfigFile.cs @@ -1,4 +1,7 @@ using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization.Metadata; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Configuration; @@ -6,6 +9,20 @@ namespace TelegramPanel.Web.Services; public static class LocalConfigFile { + public static JsonSerializerOptions CreateIndentedJsonSerializerOptions() + { + return new JsonSerializerOptions(JsonSerializerDefaults.Web) + { + WriteIndented = true, + TypeInfoResolver = new DefaultJsonTypeInfoResolver() + }; + } + + public static string ToIndentedJson(JsonNode? node) + { + return (node ?? new JsonObject()).ToJsonString(CreateIndentedJsonSerializerOptions()); + } + public static string ResolvePath(IConfiguration configuration, IWebHostEnvironment environment) { var configured = (configuration["LocalConfig:Path"] ?? "").Trim(); diff --git a/src/TelegramPanel.Web/Services/TaskAssetScopeHelper.cs b/src/TelegramPanel.Web/Services/TaskAssetScopeHelper.cs index f9f3960..a1bc61b 100644 --- a/src/TelegramPanel.Web/Services/TaskAssetScopeHelper.cs +++ b/src/TelegramPanel.Web/Services/TaskAssetScopeHelper.cs @@ -38,7 +38,7 @@ public static class TaskAssetScopeHelper return configJson; node.Remove(AssetScopeIdProperty); - return node.ToJsonString(new System.Text.Json.JsonSerializerOptions { WriteIndented = true }); + return LocalConfigFile.ToIndentedJson(node); } catch { @@ -63,7 +63,7 @@ public static class TaskAssetScopeHelper else node[AssetScopeIdProperty] = normalized; - return node.ToJsonString(new System.Text.Json.JsonSerializerOptions { WriteIndented = true }); + return LocalConfigFile.ToIndentedJson(node); } catch { diff --git a/src/TelegramPanel.Web/Services/TelegramPanelAiService.cs b/src/TelegramPanel.Web/Services/TelegramPanelAiService.cs index f39ffe1..a99f0e1 100644 --- a/src/TelegramPanel.Web/Services/TelegramPanelAiService.cs +++ b/src/TelegramPanel.Web/Services/TelegramPanelAiService.cs @@ -173,6 +173,38 @@ public sealed class TelegramPanelAiService : ITelegramPanelAiService } } + public async Task<(bool Success, string? Model, string? ResponseText, string? Error)> TestConnectionAsync( + AiOpenAiSettingsSnapshot settings, + string? overrideModel = null, + CancellationToken cancellationToken = default) + { + if (!settings.TryValidateForTask(overrideModel, out var validateError)) + return (false, null, null, validateError); + + var model = settings.ResolveModel(overrideModel)!; + + try + { + var content = await SendChatCompletionAsync( + settings, + model, + "你是 AI 连通性测试助手。请只回复 pong。", + "ping", + cancellationToken); + + var normalized = StripMarkdownFence(content).Trim(); + if (normalized.Length == 0) + return (false, model, null, "AI 返回了空响应"); + + return (true, model, TrimForPreview(normalized), null); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "AI connectivity test failed"); + return (false, model, null, ex.Message); + } + } + private async Task SendChatCompletionAsync( AiOpenAiSettingsSnapshot settings, string model, @@ -260,4 +292,10 @@ public sealed class TelegramPanelAiService : ITelegramPanelAiService var normalized = (text ?? string.Empty).Replace("\r", " ").Replace("\n", " ").Trim(); return normalized.Length <= 400 ? normalized : normalized[..400]; } + + private static string TrimForPreview(string text) + { + var normalized = (text ?? string.Empty).Replace("\r", " ").Replace("\n", " ").Trim(); + return normalized.Length <= 80 ? normalized : normalized[..80]; + } } From df096ec6aa5bc334d1d4e21d1bd3ab3a99337414 Mon Sep 17 00:00:00 2001 From: meoacgx Date: Tue, 10 Mar 2026 05:04:24 +0800 Subject: [PATCH 10/13] fix: tighten AI verification and task pause state --- .../Services/BatchTaskManagementService.cs | 32 ++++---- .../Telegram/AccountTelegramToolsService.cs | 77 ++++++++++++++++++- .../Repositories/BatchTaskRepository.cs | 22 ++++++ .../Repositories/IBatchTaskRepository.cs | 2 + .../Components/Pages/Tasks.razor | 9 +-- .../Services/TelegramPanelAiService.cs | 15 ++-- .../UserChatActiveAiVerificationService.cs | 60 ++++++++++++--- 7 files changed, 178 insertions(+), 39 deletions(-) diff --git a/src/TelegramPanel.Core/Services/BatchTaskManagementService.cs b/src/TelegramPanel.Core/Services/BatchTaskManagementService.cs index c1542a2..1c10619 100644 --- a/src/TelegramPanel.Core/Services/BatchTaskManagementService.cs +++ b/src/TelegramPanel.Core/Services/BatchTaskManagementService.cs @@ -26,7 +26,7 @@ public class BatchTaskManagementService public async Task GetTaskAsync(int id) { - return await _batchTaskRepository.GetByIdAsync(id); + return await _batchTaskRepository.GetFreshByIdAsync(id); } public async Task> GetAllTasksAsync() @@ -63,51 +63,51 @@ public class BatchTaskManagementService public async Task UpdateTaskProgressAsync(int taskId, int completed, int failed) { - var task = await _batchTaskRepository.GetByIdAsync(taskId); + var task = await _batchTaskRepository.GetFreshByIdAsync(taskId); if (task != null) { task.Completed = completed; task.Failed = failed; - await _batchTaskRepository.UpdateAsync(task); + await _batchTaskRepository.UpdateFreshAsync(task); } } public async Task UpdateTaskConfigAsync(int taskId, string? config) { - var task = await _batchTaskRepository.GetByIdAsync(taskId); + var task = await _batchTaskRepository.GetFreshByIdAsync(taskId); if (task != null) { task.Config = config; - await _batchTaskRepository.UpdateAsync(task); + await _batchTaskRepository.UpdateFreshAsync(task); } } public async Task UpdateTaskDraftAsync(int taskId, int total, string? config) { - var task = await _batchTaskRepository.GetByIdAsync(taskId); + var task = await _batchTaskRepository.GetFreshByIdAsync(taskId); if (task != null) { if (total < 0) total = 0; task.Total = total; task.Config = config; - await _batchTaskRepository.UpdateAsync(task); + await _batchTaskRepository.UpdateFreshAsync(task); } } public async Task StartTaskAsync(int taskId) { - var task = await _batchTaskRepository.GetByIdAsync(taskId); + var task = await _batchTaskRepository.GetFreshByIdAsync(taskId); if (task != null) { task.Status = "running"; task.StartedAt = DateTime.UtcNow; - await _batchTaskRepository.UpdateAsync(task); + await _batchTaskRepository.UpdateFreshAsync(task); } } public async Task PauseTaskAsync(int taskId) { - var task = await _batchTaskRepository.GetByIdAsync(taskId); + var task = await _batchTaskRepository.GetFreshByIdAsync(taskId); if (task == null) return; @@ -115,13 +115,13 @@ public class BatchTaskManagementService { task.Status = "paused"; task.CompletedAt = null; - await _batchTaskRepository.UpdateAsync(task); + await _batchTaskRepository.UpdateFreshAsync(task); } } public async Task ResumeTaskAsync(int taskId) { - var task = await _batchTaskRepository.GetByIdAsync(taskId); + var task = await _batchTaskRepository.GetFreshByIdAsync(taskId); if (task == null) return; @@ -130,25 +130,25 @@ public class BatchTaskManagementService task.Status = "pending"; task.StartedAt = null; task.CompletedAt = null; - await _batchTaskRepository.UpdateAsync(task); + await _batchTaskRepository.UpdateFreshAsync(task); } } public async Task CompleteTaskAsync(int taskId, bool success = true) { - var task = await _batchTaskRepository.GetByIdAsync(taskId); + var task = await _batchTaskRepository.GetFreshByIdAsync(taskId); if (task == null) return; task.Status = success ? "completed" : "failed"; task.CompletedAt = DateTime.UtcNow; - await _batchTaskRepository.UpdateAsync(task); + await _batchTaskRepository.UpdateFreshAsync(task); await TrimHistoryTasksIfNeededAsync(); } public async Task DeleteTaskAsync(int id) { - var task = await _batchTaskRepository.GetByIdAsync(id); + var task = await _batchTaskRepository.GetFreshByIdAsync(id); if (task != null) { await _batchTaskRepository.DeleteAsync(task); diff --git a/src/TelegramPanel.Core/Services/Telegram/AccountTelegramToolsService.cs b/src/TelegramPanel.Core/Services/Telegram/AccountTelegramToolsService.cs index c605c68..e12b27b 100644 --- a/src/TelegramPanel.Core/Services/Telegram/AccountTelegramToolsService.cs +++ b/src/TelegramPanel.Core/Services/Telegram/AccountTelegramToolsService.cs @@ -1274,7 +1274,10 @@ public class AccountTelegramToolsService var mentionsAccount = ContainsUsernameMention(update.Message.message, currentUsername); var replyToSent = update.ReplyToMessageId == sentMessageId; - return mentionsAccount || replyToSent; + if (!mentionsAccount && !replyToSent) + return false; + + return LooksLikeVerificationChallenge(update); } private async Task BuildVerificationCandidateAsync( @@ -1325,6 +1328,78 @@ public class AccountTelegramToolsService return messageText.Contains($"@{username}", StringComparison.OrdinalIgnoreCase); } + private static bool LooksLikeVerificationChallenge(TelegramAccountMessageUpdate update) + { + if (update.Buttons.Count > 0 || update.HasVisualMedia) + return true; + + var text = update.Text; + if (text.Length == 0) + return false; + + if (ContainsAny(text, "垃圾广告", "广告", "不予处理", "已删除", "违规", "封禁") + && !ContainsAny(text, "验证", "验证码", "校验", "captcha")) + { + return false; + } + + if (ContainsAny(text, + "验证", + "验证码", + "校验", + "请选择", + "点击", + "按钮", + "完成验证", + "请回复", + "答案", + "算式", + "等于多少", + "reply", + "captcha")) + { + return true; + } + + return LooksLikeMathChallenge(text); + } + + private static bool LooksLikeMathChallenge(string text) + { + var digitCount = 0; + foreach (var ch in text) + { + if (char.IsDigit(ch)) + digitCount++; + } + + if (digitCount < 2) + return false; + + return text.IndexOf('+') >= 0 + || text.IndexOf('-') >= 0 + || text.IndexOf('*') >= 0 + || text.IndexOf('/') >= 0 + || text.Contains("×", StringComparison.Ordinal) + || text.Contains("÷", StringComparison.Ordinal) + || text.Contains("=", StringComparison.Ordinal) + || text.IndexOf('=') >= 0; + } + + private static bool ContainsAny(string text, params string[] keywords) + { + foreach (var keyword in keywords) + { + if (!string.IsNullOrWhiteSpace(keyword) + && text.Contains(keyword, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + private static List ExtractInlineButtons(Message message) { if (message.reply_markup is not ReplyInlineMarkup markup) diff --git a/src/TelegramPanel.Data/Repositories/BatchTaskRepository.cs b/src/TelegramPanel.Data/Repositories/BatchTaskRepository.cs index e33169c..839841a 100644 --- a/src/TelegramPanel.Data/Repositories/BatchTaskRepository.cs +++ b/src/TelegramPanel.Data/Repositories/BatchTaskRepository.cs @@ -12,6 +12,19 @@ public class BatchTaskRepository : Repository, IBatchTaskRepository { } + public async Task GetFreshByIdAsync(int id) + { + DetachTrackedEntity(id); + return await _dbSet.AsNoTracking().FirstOrDefaultAsync(t => t.Id == id); + } + + public async Task UpdateFreshAsync(BatchTask entity) + { + DetachTrackedEntity(entity.Id); + _dbSet.Update(entity); + await SaveChangesWithSqliteLockRetryAsync(); + } + public async Task> GetByStatusAsync(string status) { return await _dbSet @@ -55,4 +68,13 @@ public class BatchTaskRepository : Repository, IBatchTaskRepository await SaveChangesWithSqliteLockRetryAsync(cancellationToken); return staleTasks.Count; } + + private void DetachTrackedEntity(int id) + { + foreach (var entry in _context.ChangeTracker.Entries()) + { + if (entry.Entity.Id == id) + entry.State = EntityState.Detached; + } + } } diff --git a/src/TelegramPanel.Data/Repositories/IBatchTaskRepository.cs b/src/TelegramPanel.Data/Repositories/IBatchTaskRepository.cs index 68c6a5a..0a3f557 100644 --- a/src/TelegramPanel.Data/Repositories/IBatchTaskRepository.cs +++ b/src/TelegramPanel.Data/Repositories/IBatchTaskRepository.cs @@ -7,6 +7,8 @@ namespace TelegramPanel.Data.Repositories; /// public interface IBatchTaskRepository : IRepository { + Task GetFreshByIdAsync(int id); + Task UpdateFreshAsync(BatchTask entity); Task> GetByStatusAsync(string status); Task> GetRunningTasksAsync(); Task> GetRecentTasksAsync(int count = 20); diff --git a/src/TelegramPanel.Web/Components/Pages/Tasks.razor b/src/TelegramPanel.Web/Components/Pages/Tasks.razor index 4260f0c..40ae804 100644 --- a/src/TelegramPanel.Web/Components/Pages/Tasks.razor +++ b/src/TelegramPanel.Web/Components/Pages/Tasks.razor @@ -474,13 +474,6 @@ private static string GetDisplayStatus(BatchTask task) { - if (task.Status == "pending" - && IsUserChatActiveTask(task.TaskType) - && task.Total == 0) - { - return "running"; - } - if (task.Status == "failed" && task.CompletedAt.HasValue && task.Total > 0 && task.Completed >= task.Total) return "completed"; @@ -565,7 +558,7 @@ private bool CanPauseTask(BatchTask task) { var capabilities = GetTaskDefinition(task)?.Definition.TaskCenter; - return capabilities?.CanPause == true && HasStatus(task, "running"); + return capabilities?.CanPause == true && task.Status is "running" or "pending"; } private bool CanResumeTask(BatchTask task) diff --git a/src/TelegramPanel.Web/Services/TelegramPanelAiService.cs b/src/TelegramPanel.Web/Services/TelegramPanelAiService.cs index a99f0e1..03eee10 100644 --- a/src/TelegramPanel.Web/Services/TelegramPanelAiService.cs +++ b/src/TelegramPanel.Web/Services/TelegramPanelAiService.cs @@ -44,11 +44,16 @@ public sealed class TelegramPanelAiService : ITelegramPanelAiService var model = settings.ResolveModel(request.Model)!; var buttons = request.Buttons ?? Array.Empty(); var buttonLines = buttons.Select(x => $"{x.Index}. {x.Text}").ToArray(); - var systemPrompt = - "你是 Telegram 验证助手。请根据消息文本、按钮列表和可选图片,输出唯一 JSON 决策。" + - "只允许输出 JSON,不要输出 Markdown,不要解释。JSON 格式:" + - "{\"mode\":\"click_button\"|\"reply_text\",\"button_index\":0,\"reply_text\":null,\"reason\":\"30字内\"}。" + - "如果需要点击按钮,mode=click_button,button_index 必须是 0 基索引;如果更适合直接回答,mode=reply_text,reply_text 必须填写最终回复内容。"; + var hasButtons = buttonLines.Length > 0; + var systemPrompt = hasButtons + ? "你是 Telegram 验证助手。当前消息存在可点击按钮。请根据消息文本、按钮列表和可选图片,只输出唯一 JSON 决策。" + + "只允许输出 JSON,不要输出 Markdown,不要解释。JSON 格式:" + + "{\"mode\":\"click_button\",\"button_index\":0,\"reply_text\":null,\"reason\":\"30字内\"}。" + + "注意:当前存在按钮时,不允许返回 reply_text,必须返回 click_button,button_index 必须是 0 基索引。" + : "你是 Telegram 验证助手。请根据消息文本、按钮列表和可选图片,输出唯一 JSON 决策。" + + "只允许输出 JSON,不要输出 Markdown,不要解释。JSON 格式:" + + "{\"mode\":\"click_button\"|\"reply_text\",\"button_index\":0,\"reply_text\":null,\"reason\":\"30字内\"}。" + + "如果需要点击按钮,mode=click_button,button_index 必须是 0 基索引;如果更适合直接回答,mode=reply_text,reply_text 必须填写最终回复内容。"; var text = new StringBuilder(); text.AppendLine("请处理下面这条 Telegram 验证消息。"); diff --git a/src/TelegramPanel.Web/Services/UserChatActiveAiVerificationService.cs b/src/TelegramPanel.Web/Services/UserChatActiveAiVerificationService.cs index 3723445..3629b8c 100644 --- a/src/TelegramPanel.Web/Services/UserChatActiveAiVerificationService.cs +++ b/src/TelegramPanel.Web/Services/UserChatActiveAiVerificationService.cs @@ -64,17 +64,23 @@ public sealed class UserChatActiveAiVerificationService if (replyText.Length == 0) return (false, "AI 返回了 reply_text,但内容为空", null); - var reply = await _accountTools.SendMessageToResolvedChatAsync( - account.Id, - target, - replyText, - replyToMessageId: candidate.MessageId, - cancellationToken: cancellationToken); + var mappedButton = TryMatchButtonByReplyText(candidate.Buttons, replyText); + if (mappedButton != null) + { + var mappedClick = await _accountTools.ClickInlineButtonAsync( + account.Id, + target, + candidate.MessageId, + mappedButton.CallbackData, + cancellationToken); - if (!reply.Success) - return (false, reply.Error, null); + if (!mappedClick.Success) + return (false, mappedClick.Error, null); - return (true, null, $"AI 文本回复:{replyText}"); + return (true, null, $"AI 文本映射按钮:{mappedButton.Text}"); + } + + return (false, $"验证码消息存在按钮,但 AI 返回了文本:{replyText}", null); } var button = candidate.Buttons.FirstOrDefault(x => x.Index == decision.ButtonIndex); @@ -134,4 +140,40 @@ public sealed class UserChatActiveAiVerificationService return (true, null, $"AI 文本回复:{replyContent}"); } + + private static TelegramInlineButtonOption? TryMatchButtonByReplyText( + IReadOnlyList buttons, + string replyText) + { + var normalizedReply = NormalizeButtonText(replyText); + if (normalizedReply.Length == 0) + return null; + + foreach (var button in buttons) + { + if (NormalizeButtonText(button.Text) == normalizedReply) + return button; + } + + foreach (var button in buttons) + { + var normalizedButton = NormalizeButtonText(button.Text); + if (normalizedButton.Contains(normalizedReply, StringComparison.OrdinalIgnoreCase) + || normalizedReply.Contains(normalizedButton, StringComparison.OrdinalIgnoreCase)) + { + return button; + } + } + + return null; + } + + private static string NormalizeButtonText(string? value) + { + return (value ?? string.Empty) + .Trim() + .Replace(" ", string.Empty, StringComparison.Ordinal) + .Replace("\r", string.Empty, StringComparison.Ordinal) + .Replace("\n", string.Empty, StringComparison.Ordinal); + } } From 47b15fae3db1d010d86d06868ca5479556c2df7d Mon Sep 17 00:00:00 2001 From: meoacgx Date: Tue, 10 Mar 2026 05:09:30 +0800 Subject: [PATCH 11/13] fix: treat bot callback timeout as delivered --- .../Telegram/AccountTelegramToolsService.cs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/TelegramPanel.Core/Services/Telegram/AccountTelegramToolsService.cs b/src/TelegramPanel.Core/Services/Telegram/AccountTelegramToolsService.cs index e12b27b..c268be2 100644 --- a/src/TelegramPanel.Core/Services/Telegram/AccountTelegramToolsService.cs +++ b/src/TelegramPanel.Core/Services/Telegram/AccountTelegramToolsService.cs @@ -1210,6 +1210,16 @@ public class AccountTelegramToolsService _ = await client.Messages_GetBotCallbackAnswer(target.Peer, messageId, callbackData, null, false); return (true, null); } + catch (Exception ex) when (IsBotCallbackTimeout(ex)) + { + _logger.LogInformation( + ex, + "Telegram bot callback timed out after click, treat as delivered: accountId={AccountId}, chat={ChatId}, messageId={MessageId}", + accountId, + target.CanonicalId, + messageId); + return (true, null); + } catch (Exception ex) { var (summary, details) = MapTelegramException(ex); @@ -2019,6 +2029,12 @@ public class AccountTelegramToolsService /// /// 将 Telegram 异常映射为可读的摘要和详情。 /// + private static bool IsBotCallbackTimeout(Exception ex) + { + var msg = ex.Message ?? string.Empty; + return msg.Contains("BOT_RESPONSE_TIMEOUT", StringComparison.OrdinalIgnoreCase); + } + public static (string summary, string details) MapTelegramException(Exception ex) { var msg = ex.Message ?? string.Empty; From 89a66fd2432a399d6efbe271ffca58c9e6aa1808 Mon Sep 17 00:00:00 2001 From: meoacgx Date: Tue, 10 Mar 2026 05:30:26 +0800 Subject: [PATCH 12/13] feat: add AI retry settings --- .../Components/Pages/Settings.razor | 25 +- .../Services/AiOpenAiOptions.cs | 18 +- .../Services/TelegramPanelAiService.cs | 226 ++++++++++++++---- 3 files changed, 214 insertions(+), 55 deletions(-) diff --git a/src/TelegramPanel.Web/Components/Pages/Settings.razor b/src/TelegramPanel.Web/Components/Pages/Settings.razor index 2832312..62df050 100644 --- a/src/TelegramPanel.Web/Components/Pages/Settings.razor +++ b/src/TelegramPanel.Web/Components/Pages/Settings.razor @@ -98,8 +98,11 @@ + - 测试连通会直接使用当前表单里的端点、Key 和模型,不必先保存。 + 测试连通会直接使用当前表单里的端点、Key、模型和重试次数,不必先保存。 @@ -331,6 +334,7 @@ private string aiApiKey = ""; private string aiDefaultModel = ""; private string aiPresetModelsText = ""; + private int aiRetryCount = 2; private bool aiTesting = false; private sealed record TimeZoneChoice(string Id, string Label); @@ -383,6 +387,7 @@ aiApiKey = Configuration["AI:OpenAI:ApiKey"] ?? ""; aiDefaultModel = Configuration["AI:OpenAI:DefaultModel"] ?? ""; aiPresetModelsText = string.Join(System.Environment.NewLine, Configuration.GetSection("AI:OpenAI:PresetModels").GetChildren().Select(x => x.Value).Where(x => !string.IsNullOrWhiteSpace(x))); + aiRetryCount = AiOpenAiSettingsSnapshot.NormalizeRetryCount(Configuration.GetValue("AI:OpenAI:RetryCount", 2)); } private async Task SaveApiConfig() @@ -484,12 +489,19 @@ private async Task SaveAiConfig() { + if (aiRetryCount < 0 || aiRetryCount > 5) + { + Snackbar.Add("AI 失败重试次数范围应为 0-5", Severity.Error); + return; + } + try { var normalizedEndpoint = AiOpenAiSettingsSnapshot.NormalizeEndpoint(aiEndpoint); var normalizedApiKey = AiOpenAiSettingsSnapshot.NormalizeApiKey(aiApiKey); var normalizedDefaultModel = AiOpenAiSettingsSnapshot.NormalizeModel(aiDefaultModel); var presetModels = AiOpenAiSettingsSnapshot.NormalizeModelEntries(ParseLines(aiPresetModelsText), normalizedDefaultModel); + var normalizedRetryCount = AiOpenAiSettingsSnapshot.NormalizeRetryCount(aiRetryCount); var root = await LoadOrCreateLocalConfigAsync(); var ai = EnsureObject(root, "AI"); @@ -498,6 +510,7 @@ openAi["Endpoint"] = normalizedEndpoint ?? string.Empty; openAi["ApiKey"] = normalizedApiKey ?? string.Empty; openAi["DefaultModel"] = normalizedDefaultModel ?? string.Empty; + openAi["RetryCount"] = normalizedRetryCount; var modelsArray = new System.Text.Json.Nodes.JsonArray(); foreach (var model in presetModels) @@ -510,6 +523,7 @@ aiApiKey = normalizedApiKey ?? string.Empty; aiDefaultModel = normalizedDefaultModel ?? string.Empty; aiPresetModelsText = string.Join(System.Environment.NewLine, presetModels); + aiRetryCount = normalizedRetryCount; Snackbar.Add("AI 配置已保存(配置热重载后生效)", Severity.Success); StateHasChanged(); @@ -525,6 +539,12 @@ if (aiTesting) return; + if (aiRetryCount < 0 || aiRetryCount > 5) + { + Snackbar.Add("AI 失败重试次数范围应为 0-5", Severity.Error); + return; + } + aiTesting = true; try { @@ -532,7 +552,8 @@ var normalizedApiKey = AiOpenAiSettingsSnapshot.NormalizeApiKey(aiApiKey); var normalizedDefaultModel = AiOpenAiSettingsSnapshot.NormalizeModel(aiDefaultModel); var presetModels = AiOpenAiSettingsSnapshot.NormalizeModelEntries(ParseLines(aiPresetModelsText), normalizedDefaultModel); - var snapshot = new AiOpenAiSettingsSnapshot(normalizedEndpoint, normalizedApiKey, normalizedDefaultModel, presetModels); + var normalizedRetryCount = AiOpenAiSettingsSnapshot.NormalizeRetryCount(aiRetryCount); + var snapshot = new AiOpenAiSettingsSnapshot(normalizedEndpoint, normalizedApiKey, normalizedDefaultModel, presetModels, normalizedRetryCount); var testModel = ResolveAiTestModel(snapshot); if (!snapshot.TryValidateForTask(testModel, out var validateError)) diff --git a/src/TelegramPanel.Web/Services/AiOpenAiOptions.cs b/src/TelegramPanel.Web/Services/AiOpenAiOptions.cs index 48f2954..03722e7 100644 --- a/src/TelegramPanel.Web/Services/AiOpenAiOptions.cs +++ b/src/TelegramPanel.Web/Services/AiOpenAiOptions.cs @@ -10,6 +10,8 @@ public sealed class AiOpenAiOptions public List PresetModels { get; set; } = new(); + public int RetryCount { get; set; } = 2; + public AiOpenAiSettingsSnapshot ToSnapshot() { return AiOpenAiSettingsSnapshot.From(this); @@ -20,9 +22,10 @@ public sealed record AiOpenAiSettingsSnapshot( string? Endpoint, string? ApiKey, string? DefaultModel, - IReadOnlyList PresetModels) + IReadOnlyList PresetModels, + int RetryCount) { - public static AiOpenAiSettingsSnapshot Empty { get; } = new(null, null, null, Array.Empty()); + public static AiOpenAiSettingsSnapshot Empty { get; } = new(null, null, null, Array.Empty(), 2); public string CompletionsPath => "/chat/completions"; @@ -115,7 +118,8 @@ public sealed record AiOpenAiSettingsSnapshot( NormalizeEndpoint(options.Endpoint), NormalizeApiKey(options.ApiKey), defaultModel, - presetModels); + presetModels, + NormalizeRetryCount(options.RetryCount)); } public static string? NormalizeEndpoint(string? value) @@ -160,6 +164,14 @@ public sealed record AiOpenAiSettingsSnapshot( return model.Length == 0 ? null : model; } + public static int NormalizeRetryCount(int value) + { + if (value < 0) + return 0; + + return value > 5 ? 5 : value; + } + public static List NormalizeModelEntries(IEnumerable? values, string? defaultModel = null) { var normalizedDefault = NormalizeModel(defaultModel); diff --git a/src/TelegramPanel.Web/Services/TelegramPanelAiService.cs b/src/TelegramPanel.Web/Services/TelegramPanelAiService.cs index 03eee10..fbac53f 100644 --- a/src/TelegramPanel.Web/Services/TelegramPanelAiService.cs +++ b/src/TelegramPanel.Web/Services/TelegramPanelAiService.cs @@ -82,46 +82,16 @@ public sealed class TelegramPanelAiService : ITelegramPanelAiService try { - var content = await SendChatCompletionAsync(settings, model, systemPrompt, userContent, cancellationToken); - var json = ExtractJsonObject(content); - using var document = JsonDocument.Parse(json); - var root = document.RootElement; - var mode = root.TryGetProperty("mode", out var modeElement) - ? (modeElement.GetString() ?? string.Empty).Trim() - : string.Empty; - var reason = root.TryGetProperty("reason", out var reasonElement) - ? reasonElement.GetString() - : null; - - if (string.Equals(mode, "click_button", StringComparison.OrdinalIgnoreCase)) - { - if (!root.TryGetProperty("button_index", out var buttonIndexElement) - || buttonIndexElement.ValueKind != JsonValueKind.Number - || !buttonIndexElement.TryGetInt32(out var buttonIndex)) - { - return new TelegramPanelAiChooseActionResult(false, null, null, null, null, "AI 返回了点击按钮模式,但缺少有效的 button_index"); - } - - return new TelegramPanelAiChooseActionResult(true, "click_button", buttonIndex, null, reason, null); - } - - if (string.Equals(mode, "reply_text", StringComparison.OrdinalIgnoreCase)) - { - var replyText = root.TryGetProperty("reply_text", out var replyElement) - ? (replyElement.GetString() ?? string.Empty).Trim() - : string.Empty; - - if (replyText.Length == 0) - return new TelegramPanelAiChooseActionResult(false, null, null, null, null, "AI 返回了 reply_text 模式,但 reply_text 为空"); - - return new TelegramPanelAiChooseActionResult(true, "reply_text", null, replyText, reason, null); - } - - return new TelegramPanelAiChooseActionResult(false, null, null, null, null, $"AI 返回了未知模式:{mode}"); + return await ExecuteWithRetryAsync( + operationName: "验证决策", + model: model, + retryCount: settings.RetryCount, + action: ct => ChooseActionCoreAsync(settings, model, systemPrompt, userContent, ct), + cancellationToken: cancellationToken); } catch (Exception ex) { - _logger.LogWarning(ex, "Failed to parse AI choice response"); + _logger.LogWarning(ex, "Failed to parse AI choice response after retries"); return new TelegramPanelAiChooseActionResult(false, null, null, null, null, $"AI 响应解析失败:{ex.Message}"); } } @@ -164,16 +134,24 @@ public sealed class TelegramPanelAiService : ITelegramPanelAiService try { - var content = await SendChatCompletionAsync(settings, model, systemPrompt, userContent, cancellationToken); - var normalized = StripMarkdownFence(content).Trim(); - if (normalized.Length == 0) - return new TelegramPanelAiReplyTextResult(false, null, "AI 返回了空回复"); + return await ExecuteWithRetryAsync( + operationName: "文本作答", + model: model, + retryCount: settings.RetryCount, + action: async ct => + { + var content = await SendChatCompletionAsync(settings, model, systemPrompt, userContent, ct); + var normalized = StripMarkdownFence(content).Trim(); + if (normalized.Length == 0) + throw new InvalidOperationException("AI 返回了空回复"); - return new TelegramPanelAiReplyTextResult(true, normalized, null); + return new TelegramPanelAiReplyTextResult(true, normalized, null); + }, + cancellationToken: cancellationToken); } catch (Exception ex) { - _logger.LogWarning(ex, "Failed to generate AI reply text"); + _logger.LogWarning(ex, "Failed to generate AI reply text after retries"); return new TelegramPanelAiReplyTextResult(false, null, $"AI 请求失败:{ex.Message}"); } } @@ -190,12 +168,17 @@ public sealed class TelegramPanelAiService : ITelegramPanelAiService try { - var content = await SendChatCompletionAsync( - settings, - model, - "你是 AI 连通性测试助手。请只回复 pong。", - "ping", - cancellationToken); + var content = await ExecuteWithRetryAsync( + operationName: "连通测试", + model: model, + retryCount: settings.RetryCount, + action: ct => SendChatCompletionAsync( + settings, + model, + "你是 AI 连通性测试助手。请只回复 pong。", + "ping", + ct), + cancellationToken: cancellationToken); var normalized = StripMarkdownFence(content).Trim(); if (normalized.Length == 0) @@ -205,11 +188,97 @@ public sealed class TelegramPanelAiService : ITelegramPanelAiService } catch (Exception ex) { - _logger.LogWarning(ex, "AI connectivity test failed"); + _logger.LogWarning(ex, "AI connectivity test failed after retries"); return (false, model, null, ex.Message); } } + private async Task ChooseActionCoreAsync( + AiOpenAiSettingsSnapshot settings, + string model, + string systemPrompt, + object userContent, + CancellationToken cancellationToken) + { + var content = await SendChatCompletionAsync(settings, model, systemPrompt, userContent, cancellationToken); + var json = ExtractJsonObject(content); + using var document = JsonDocument.Parse(json); + var root = document.RootElement; + var mode = root.TryGetProperty("mode", out var modeElement) + ? (modeElement.GetString() ?? string.Empty).Trim() + : string.Empty; + var reason = root.TryGetProperty("reason", out var reasonElement) + ? reasonElement.GetString() + : null; + + if (string.Equals(mode, "click_button", StringComparison.OrdinalIgnoreCase)) + { + if (!root.TryGetProperty("button_index", out var buttonIndexElement) + || buttonIndexElement.ValueKind != JsonValueKind.Number + || !buttonIndexElement.TryGetInt32(out var buttonIndex)) + { + throw new InvalidOperationException("AI 返回了点击按钮模式,但缺少有效的 button_index"); + } + + return new TelegramPanelAiChooseActionResult(true, "click_button", buttonIndex, null, reason, null); + } + + if (string.Equals(mode, "reply_text", StringComparison.OrdinalIgnoreCase)) + { + var replyText = root.TryGetProperty("reply_text", out var replyElement) + ? (replyElement.GetString() ?? string.Empty).Trim() + : string.Empty; + + if (replyText.Length == 0) + throw new InvalidOperationException("AI 返回了 reply_text 模式,但 reply_text 为空"); + + return new TelegramPanelAiChooseActionResult(true, "reply_text", null, replyText, reason, null); + } + + throw new InvalidOperationException($"AI 返回了未知模式:{mode}"); + } + + private async Task ExecuteWithRetryAsync( + string operationName, + string model, + int retryCount, + Func> action, + CancellationToken cancellationToken) + { + var normalizedRetryCount = AiOpenAiSettingsSnapshot.NormalizeRetryCount(retryCount); + var maxAttempts = Math.Max(1, normalizedRetryCount + 1); + Exception? lastException = null; + + for (var attempt = 1; attempt <= maxAttempts; attempt++) + { + try + { + return await action(cancellationToken); + } + catch (Exception ex) + { + lastException = ex; + if (attempt >= maxAttempts || !IsRetryableAiFailure(ex, cancellationToken)) + break; + + var delay = GetRetryDelay(attempt); + _logger.LogWarning( + ex, + "AI {OperationName} 失败,准备重试:第 {NextAttempt}/{MaxAttempts} 次,模型={Model},等待 {DelayMs}ms", + operationName, + attempt + 1, + maxAttempts, + model, + (int)delay.TotalMilliseconds); + + if (delay > TimeSpan.Zero) + await Task.Delay(delay, cancellationToken); + } + } + + throw lastException ?? new InvalidOperationException($"AI {operationName} 失败"); + } + private async Task SendChatCompletionAsync( AiOpenAiSettingsSnapshot settings, string model, @@ -266,6 +335,63 @@ public sealed class TelegramPanelAiService : ITelegramPanelAiService } } + private static bool IsRetryableAiFailure(Exception ex, CancellationToken cancellationToken) + { + if (ex is OperationCanceledException && cancellationToken.IsCancellationRequested) + return false; + + if (ex is HttpRequestException) + return true; + + if (ex is TaskCanceledException) + return true; + + var message = ex.Message ?? string.Empty; + if (message.Contains("端点未配置", StringComparison.OrdinalIgnoreCase) + || message.Contains("Key 未配置", StringComparison.OrdinalIgnoreCase) + || message.Contains("未配置 AI", StringComparison.OrdinalIgnoreCase) + || message.Contains("端点格式无效", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (TryGetHttpStatusCode(ex, out var statusCode)) + return statusCode == 408 || statusCode == 409 || statusCode == 429 || statusCode >= 500; + + return true; + } + + private static bool TryGetHttpStatusCode(Exception ex, out int statusCode) + { + const string marker = "AI 请求失败(HTTP "; + var message = ex.Message ?? string.Empty; + var start = message.IndexOf(marker, StringComparison.Ordinal); + if (start < 0) + { + statusCode = 0; + return false; + } + + start += marker.Length; + var end = message.IndexOf(')', start); + if (end < 0) + end = message.IndexOf(')', start); + + if (end <= start) + { + statusCode = 0; + return false; + } + + return int.TryParse(message[start..end], out statusCode); + } + + private static TimeSpan GetRetryDelay(int attempt) + { + var delayMs = Math.Min(3000, 800 * Math.Max(1, attempt)); + return TimeSpan.FromMilliseconds(delayMs); + } + private static string ExtractJsonObject(string content) { var normalized = StripMarkdownFence(content).Trim(); From afee48427377b02f9a40b911333d9311e59af9c3 Mon Sep 17 00:00:00 2001 From: meoacgx Date: Tue, 10 Mar 2026 05:50:22 +0800 Subject: [PATCH 13/13] chore: bump version to 1.31.7 --- Directory.Build.props | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index c27c5d0..67acfc4 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -5,10 +5,10 @@ - 1.31.6 - 1.31.6.0 - 1.31.6.0 - 1.31.6 + 1.31.7 + 1.31.7.0 + 1.31.7.0 + 1.31.7 Telegram Panel Contributors