merge: dev into main for v1.31.7

This commit is contained in:
meoacgx committed 2026-03-10 05:50:58 +08:00
commit cac15092fa
79 files changed
+8408 -391

No files matched your search

+4 -4
View File
@@ -5,10 +5,10 @@
<!-- 主版本: 重大变更/不兼容更新 -->
<!-- 次版本: 新功能/兼容性更新 -->
<!-- 修订号: Bug修复/小改进 -->
<Version>1.31.6</Version>
<AssemblyVersion>1.31.6.0</AssemblyVersion>
<FileVersion>1.31.6.0</FileVersion>
<InformationalVersion>1.31.6</InformationalVersion>
<Version>1.31.7</Version>
<AssemblyVersion>1.31.7.0</AssemblyVersion>
<FileVersion>1.31.7.0</FileVersion>
<InformationalVersion>1.31.7</InformationalVersion>
<!-- 项目元信息 -->
<Authors>Telegram Panel Contributors</Authors>
+1 -1
View File
@@ -1,4 +1,4 @@
Microsoft Visual Studio Solution File, Format Version 12.00
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
+52 -1
View File
@@ -534,7 +534,56 @@ yield return new ModuleTaskDefinition
> 注意:这组字段已经进入抽象层,并且内置持续任务已按此方式声明;外部模块也建议遵循相同结构,便于后续宿主统一扩展任务中心行为。
### 6) 为“重新运行”提供专用构建器(适合复杂任务)
### 6) 宿主内置数据字典与模板变量(推荐优先复用)
如果你的模块任务需要“随机文案 / 队列文案 / 图片变量 / 标题模板 / 用户名模板”等能力,建议优先复用宿主已经内置的数据字典体系,而不是在模块里重复造一套词库配置。
当前宿主已经提供:
- 数据字典管理页面:`/data-dictionaries`
- 文本字典:返回 `string`
- 图片字典:返回图片资产引用(适合头像、图片消息等)
- 读取模式:`random` / `queue`
- 队列游标持久化:`queue` 模式的 `NextIndex` 会写入数据库,重启后继续
- 模板变量语法:固定为 `{name}`
- 内置变量:`{time}`(格式 `yyyyMMddHHmmss`)
相关宿主服务:
- `TelegramPanel.Web.Services.DataDictionaryService`
- `TelegramPanel.Web.Services.TemplateRenderingService`
- `TelegramPanel.Web.Services.ImageAssetStorageService`
推荐用法:
```csharp
var templateRendering = host.Services.GetRequiredService<TemplateRenderingService>();
var title = await templateRendering.RenderTextTemplateAsync("临时频道{time}_{city}", cancellationToken);
var avatar = await templateRendering.ResolveImageTemplateAsync("{avatar_dict}", cancellationToken);
```
约束说明:
- 标题、描述、公开用户名这类文本字段,只能解析到**文本值**
- 头像、图片消息这类图片字段,只能使用**固定图片**或**图片字典变量**
- 文本字典和图片字典**严格分型**,不要混用
- 未知变量、空字典、已停用字典、类型不匹配,宿主会直接抛出校验失败
- 图片变量必须是**单个 token**,例如 `{avatar}`,不能写成 `头像_{avatar}`
如果你的模块也提供任务编辑器,建议:
- 在 UI 中直接提示“支持 `{time}` 与 `{字典名}`”
- 文本输入框只展示文本字典变量
- 图片输入框只展示图片字典变量
- 让最终配置 JSON 只保存模板字符串 / 字典 token,不要把解析后的随机结果提前固化进配置
这样做的好处是:
- 宿主统一管理字典内容,模块间可以复用同一份变量源
- 后续扩展新变量 provider 时,模块通常不需要改协议
- 计划任务、一次性任务、模块页面都能复用同一套解析规则
### 7) 为“重新运行”提供专用构建器(适合复杂任务)
如果你的任务配置在运行过程中会写回运行态字段,或者重跑前需要清洗旧配置,建议额外实现 `IModuleTaskRerunBuilder`:
@@ -666,3 +715,5 @@ public IEnumerable<ModuleApiTypeDefinition> GetApis(ModuleHostContext context)
@@ -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";
@@ -82,6 +82,11 @@ public interface IChannelService
/// </summary>
Task<bool> UpdateChannelInfoAsync(int accountId, long channelId, string title, string? about);
/// <summary>
/// 设置频道头像。
/// </summary>
Task<bool> SetChannelPhotoAsync(int accountId, long channelId, Stream fileStream, string fileName, CancellationToken cancellationToken = default);
/// <summary>
/// 从频道踢出用户(通过 username),可选是否永久封禁
/// </summary>
@@ -46,4 +46,19 @@ public interface IGroupService
/// 获取群组管理员列表(需要权限)
/// </summary>
Task<List<ChannelAdminInfo>> GetAdminsAsync(int accountId, long groupId);
/// <summary>
/// 更新群组标题与简介。
/// </summary>
Task<bool> UpdateGroupInfoAsync(int accountId, long groupId, string title, string? about);
/// <summary>
/// 设置群组公开用户名或切回私密。
/// </summary>
Task<bool> SetGroupVisibilityAsync(int accountId, long groupId, bool isPublic, string? username = null);
/// <summary>
/// 设置群组头像。
/// </summary>
Task<bool> SetGroupPhotoAsync(int accountId, long groupId, Stream fileStream, string fileName, CancellationToken cancellationToken = default);
}
@@ -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<TelegramInlineButton> Buttons)
{
public string Text => (Message.message ?? string.Empty).Trim();
public bool HasVisualMedia => Message.media is MessageMediaPhoto or MessageMediaDocument;
}
@@ -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<TelegramInlineButtonOption> Buttons,
bool MentionsAccount,
bool IsReplyToSentMessage,
DateTime DateUtc);
@@ -12,6 +12,8 @@ public static class ServiceCollectionExtensions
{
public static IServiceCollection AddTelegramPanelCore(this IServiceCollection services)
{
services.AddSingleton<TelegramAccountUpdateHub>();
// 注册 Telegram 客户端池(单例)
services.AddSingleton<ITelegramClientPool, TelegramClientPool>();
@@ -26,7 +26,7 @@ public class BatchTaskManagementService
public async Task<BatchTask?> GetTaskAsync(int id)
{
return await _batchTaskRepository.GetByIdAsync(id);
return await _batchTaskRepository.GetFreshByIdAsync(id);
}
public async Task<IEnumerable<BatchTask>> 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);
@@ -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);
}
}
@@ -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);
}
}
@@ -24,17 +24,20 @@ public class AccountTelegramToolsService
private readonly ITelegramClientPool _clientPool;
private readonly IConfiguration _configuration;
private readonly ILogger<AccountTelegramToolsService> _logger;
private readonly TelegramAccountUpdateHub _updateHub;
public AccountTelegramToolsService(
AccountManagementService accountManagement,
ITelegramClientPool clientPool,
IConfiguration configuration,
ILogger<AccountTelegramToolsService> logger)
ILogger<AccountTelegramToolsService> logger,
TelegramAccountUpdateHub updateHub)
{
_accountManagement = accountManagement;
_clientPool = clientPool;
_configuration = configuration;
_logger = logger;
_updateHub = updateHub;
}
/// <summary>
@@ -1112,22 +1115,109 @@ public class AccountTelegramToolsService
/// <summary>
/// 向已解析的群组/频道目标发送文本消息。
/// </summary>
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) 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)
@@ -1180,6 +1270,223 @@ 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;
if (!mentionsAccount && !replyToSent)
return false;
return LooksLikeVerificationChallenge(update);
}
private async Task<TelegramVerificationMessageCandidate?> 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 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<TelegramInlineButtonOption> ExtractInlineButtons(Message message)
{
if (message.reply_markup is not ReplyInlineMarkup markup)
return new List<TelegramInlineButtonOption>();
var result = new List<TelegramInlineButtonOption>();
var index = 0;
foreach (var row in markup.rows ?? Array.Empty<KeyboardButtonRow>())
{
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<byte[]?> 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<byte[]?> 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<byte[]?> DownloadDocumentPreviewAsJpegAsync(Client client, Document document, CancellationToken cancellationToken)
{
await using var raw = new MemoryStream();
var thumb = document.thumbs?.OfType<PhotoSizeBase>().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;
@@ -1403,29 +1710,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();
@@ -1744,6 +2029,12 @@ public class AccountTelegramToolsService
/// <summary>
/// 将 Telegram 异常映射为可读的摘要和详情。
/// </summary>
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;
@@ -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,
@@ -868,6 +868,45 @@ public class ChannelService : IChannelService
return true;
}
public async Task<bool> 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<bool> KickUserAsync(int accountId, long channelId, string username, bool permanentBan = false)
{
var client = await GetOrCreateConnectedClientAsync(accountId);
@@ -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<bool> 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<bool> 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<bool> 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<Channel> 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<Channel>()
.FirstOrDefault(x => x.IsActive && !x.IsChannel && x.id == normalizedId);
if (mega != null)
return mega;
if (dialogs.chats.Values.OfType<Chat>().Any(x => x.IsActive && x.id == normalizedId))
throw new InvalidOperationException("当前仅支持超级群组自动修改资料/公开状态");
throw new InvalidOperationException($"群组 {groupId} not found");
}
public async Task<bool> LeaveGroupAsync(int accountId, long groupId)
{
try
@@ -0,0 +1,92 @@
using System.Collections.Concurrent;
using TelegramPanel.Core.Models;
namespace TelegramPanel.Core.Services.Telegram;
/// <summary>
/// 聚合账号侧收到的 Telegram 消息更新,供后台任务按账号/聊天做短期等待。
/// </summary>
public sealed class TelegramAccountUpdateHub
{
private static readonly TimeSpan MessageRetention = TimeSpan.FromMinutes(10);
private const int MaxMessagesPerAccount = 200;
private readonly ConcurrentDictionary<int, ConcurrentQueue<TelegramAccountMessageUpdate>> _messagesByAccount = new();
public Task PublishAsync(TelegramAccountMessageUpdate update)
{
var queue = _messagesByAccount.GetOrAdd(update.AccountId, _ => new ConcurrentQueue<TelegramAccountMessageUpdate>());
queue.Enqueue(update);
TrimQueue(queue);
return Task.CompletedTask;
}
public IReadOnlyList<TelegramAccountMessageUpdate> GetRecentMessages(int accountId, DateTimeOffset sinceUtc)
{
if (!_messagesByAccount.TryGetValue(accountId, out var queue))
return Array.Empty<TelegramAccountMessageUpdate>();
return queue
.ToArray()
.Where(x => x.ReceivedAtUtc >= sinceUtc)
.OrderBy(x => x.ReceivedAtUtc)
.ToList();
}
public async Task<TelegramAccountMessageUpdate?> WaitForAsync(
int accountId,
Func<TelegramAccountMessageUpdate, bool> 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<string>(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<TelegramAccountMessageUpdate> queue)
{
var cutoff = DateTimeOffset.UtcNow - MessageRetention;
while (queue.TryPeek(out var item))
{
if (queue.Count <= MaxMessagesPerAccount && item.ReceivedAtUtc >= cutoff)
break;
queue.TryDequeue(out _);
}
}
}
@@ -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<int, SemaphoreSlim> _locks = new();
private readonly IConfiguration _configuration;
private readonly ILogger<TelegramClientPool> _logger;
private readonly TelegramAccountUpdateHub _updateHub;
private bool _disposed;
private static int _wtelegramLogConfigured;
private readonly ConcurrentDictionary<int, UpdateManager> _updateManagers = new();
public TelegramClientPool(IConfiguration configuration, ILogger<TelegramClientPool> logger)
public TelegramClientPool(
IConfiguration configuration,
ILogger<TelegramClientPool> 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<TelegramInlineButton> ExtractInlineButtons(ReplyMarkup? replyMarkup)
{
if (replyMarkup is not ReplyInlineMarkup inlineMarkup || inlineMarkup.rows == null || inlineMarkup.rows.Length == 0)
return Array.Empty<TelegramInlineButton>();
var result = new List<TelegramInlineButton>();
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;
}
}
@@ -0,0 +1,73 @@
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats.Jpeg;
using SixLabors.ImageSharp.Processing;
namespace TelegramPanel.Core.Services.Telegram;
/// <summary>
/// Telegram 图片处理工具。
/// </summary>
public static class TelegramImageProcessor
{
public static async Task<MemoryStream> 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<MemoryStream> 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;
}
}
+51
View File
@@ -22,6 +22,9 @@ public class AppDbContext : DbContext
public DbSet<Group> Groups => Set<Group>();
public DbSet<GroupCategory> GroupCategories => Set<GroupCategory>();
public DbSet<BatchTask> BatchTasks => Set<BatchTask>();
public DbSet<ScheduledTask> ScheduledTasks => Set<ScheduledTask>();
public DbSet<DataDictionary> DataDictionaries => Set<DataDictionary>();
public DbSet<DataDictionaryItem> DataDictionaryItems => Set<DataDictionaryItem>();
public DbSet<Bot> Bots => Set<Bot>();
public DbSet<BotChannel> BotChannels => Set<BotChannel>();
public DbSet<BotChannelCategory> BotChannelCategories => Set<BotChannelCategory>();
@@ -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<ScheduledTask>(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<DataDictionary>(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<DataDictionaryItem>(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<Bot>(entity =>
{
@@ -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;
// 导航属性
@@ -0,0 +1,38 @@
namespace TelegramPanel.Data.Entities;
/// <summary>
/// 数据字典类型常量。
/// </summary>
public static class DataDictionaryTypes
{
public const string Text = "text";
public const string Image = "image";
}
/// <summary>
/// 数据字典读取模式常量。
/// </summary>
public static class DataDictionaryReadModes
{
public const string Random = "random";
public const string Queue = "queue";
}
/// <summary>
/// 数据字典。
/// </summary>
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<DataDictionaryItem> Items { get; set; } = new List<DataDictionaryItem>();
}
@@ -0,0 +1,18 @@
namespace TelegramPanel.Data.Entities;
/// <summary>
/// 数据字典项。
/// </summary>
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!;
}
+1
View File
@@ -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;
// 导航属性
@@ -0,0 +1,29 @@
namespace TelegramPanel.Data.Entities;
/// <summary>
/// 计划任务状态常量。
/// </summary>
public static class ScheduledTaskStatuses
{
public const string Enabled = "enabled";
public const string Paused = "paused";
}
/// <summary>
/// Cron 计划任务定义。
/// </summary>
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;
}
@@ -0,0 +1,917 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ApiHash")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<int>("ApiId")
.HasColumnType("INTEGER");
b.Property<int?>("CategoryId")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<DateTime?>("EstimatedRegistrationAt")
.HasColumnType("TEXT");
b.Property<DateTime?>("EstimatedRegistrationCheckedAtUtc")
.HasColumnType("TEXT");
b.Property<bool>("IsActive")
.HasColumnType("INTEGER");
b.Property<DateTime?>("LastLoginAt")
.HasColumnType("TEXT");
b.Property<DateTime>("LastSyncAt")
.HasColumnType("TEXT");
b.Property<string>("Nickname")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("Phone")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("TEXT");
b.Property<string>("SessionPath")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<DateTime?>("TelegramStatusCheckedAtUtc")
.HasColumnType("TEXT");
b.Property<string>("TelegramStatusDetails")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<bool?>("TelegramStatusOk")
.HasColumnType("INTEGER");
b.Property<string>("TelegramStatusSummary")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("TwoFactorPassword")
.HasColumnType("TEXT");
b.Property<long>("UserId")
.HasColumnType("INTEGER");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Color")
.HasMaxLength(20)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<bool>("ExcludeFromOperations")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false);
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AccountId")
.HasColumnType("INTEGER");
b.Property<int>("ChannelId")
.HasColumnType("INTEGER");
b.Property<bool>("IsAdmin")
.HasColumnType("INTEGER");
b.Property<bool>("IsCreator")
.HasColumnType("INTEGER");
b.Property<DateTime>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AccountId")
.HasColumnType("INTEGER");
b.Property<int>("GroupId")
.HasColumnType("INTEGER");
b.Property<bool>("IsAdmin")
.HasColumnType("INTEGER");
b.Property<bool>("IsCreator")
.HasColumnType("INTEGER");
b.Property<DateTime>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Completed")
.HasColumnType("INTEGER");
b.Property<DateTime?>("CompletedAt")
.HasColumnType("TEXT");
b.Property<string>("Config")
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("Failed")
.HasColumnType("INTEGER");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("TEXT");
b.Property<string>("TaskType")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<int>("Total")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("Status");
b.ToTable("BatchTasks");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.Bot", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<bool>("IsActive")
.HasColumnType("INTEGER");
b.Property<DateTime?>("LastSyncAt")
.HasColumnType("TEXT");
b.Property<long?>("LastUpdateId")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("Token")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("About")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<long?>("AccessHash")
.HasColumnType("INTEGER");
b.Property<int?>("CategoryId")
.HasColumnType("INTEGER");
b.Property<DateTime?>("ChannelStatusCheckedAtUtc")
.HasColumnType("TEXT");
b.Property<string>("ChannelStatusError")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<bool?>("ChannelStatusOk")
.HasColumnType("INTEGER");
b.Property<DateTime?>("CreatedAt")
.HasColumnType("TEXT");
b.Property<bool>("IsBroadcast")
.HasColumnType("INTEGER");
b.Property<int>("MemberCount")
.HasColumnType("INTEGER");
b.Property<DateTime>("SyncedAt")
.HasColumnType("TEXT");
b.Property<long>("TelegramId")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("BotChannelId")
.HasColumnType("INTEGER");
b.Property<int>("BotId")
.HasColumnType("INTEGER");
b.Property<DateTime>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("About")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<long?>("AccessHash")
.HasColumnType("INTEGER");
b.Property<DateTime?>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int?>("CreatorAccountId")
.HasColumnType("INTEGER");
b.Property<int?>("GroupId")
.HasColumnType("INTEGER");
b.Property<bool>("IsBroadcast")
.HasColumnType("INTEGER");
b.Property<int>("MemberCount")
.HasColumnType("INTEGER");
b.Property<DateTime>("SyncedAt")
.HasColumnType("TEXT");
b.Property<DateTime?>("SystemCreatedAtUtc")
.HasColumnType("TEXT");
b.Property<long>("TelegramId")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<bool>("IsEnabled")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<int>("NextIndex")
.HasColumnType("INTEGER");
b.Property<string>("ReadMode")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("TEXT");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("TEXT");
b.Property<DateTime>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AssetPath")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("DataDictionaryId")
.HasColumnType("INTEGER");
b.Property<string>("FileName")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<bool>("IsEnabled")
.HasColumnType("INTEGER");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("About")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<long?>("AccessHash")
.HasColumnType("INTEGER");
b.Property<int?>("CategoryId")
.HasColumnType("INTEGER");
b.Property<DateTime?>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int?>("CreatorAccountId")
.HasColumnType("INTEGER");
b.Property<int>("MemberCount")
.HasColumnType("INTEGER");
b.Property<DateTime>("SyncedAt")
.HasColumnType("TEXT");
b.Property<DateTime?>("SystemCreatedAtUtc")
.HasColumnType("TEXT");
b.Property<long>("TelegramId")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ConfigJson")
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("CronExpression")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<int?>("LastBatchTaskId")
.HasColumnType("INTEGER");
b.Property<DateTime?>("LastRunAtUtc")
.HasColumnType("TEXT");
b.Property<DateTime?>("NextRunAtUtc")
.HasColumnType("TEXT");
b.Property<string>("OwnedAssetScopeId")
.HasMaxLength(120)
.HasColumnType("TEXT");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("TEXT");
b.Property<string>("TaskType")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<int>("Total")
.HasColumnType("INTEGER");
b.Property<DateTime>("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
}
}
}
@@ -0,0 +1,158 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TelegramPanel.Data.Migrations
{
/// <inheritdoc />
public partial class AddScheduledTasksAndDataDictionaries : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "SystemCreatedAtUtc",
table: "Groups",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "SystemCreatedAtUtc",
table: "Channels",
type: "TEXT",
nullable: true);
migrationBuilder.CreateTable(
name: "DataDictionaries",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Name = table.Column<string>(type: "TEXT", maxLength: 100, nullable: false),
DisplayName = table.Column<string>(type: "TEXT", maxLength: 100, nullable: false),
Description = table.Column<string>(type: "TEXT", maxLength: 500, nullable: true),
Type = table.Column<string>(type: "TEXT", maxLength: 20, nullable: false),
ReadMode = table.Column<string>(type: "TEXT", maxLength: 20, nullable: false),
IsEnabled = table.Column<bool>(type: "INTEGER", nullable: false),
NextIndex = table.Column<int>(type: "INTEGER", nullable: false),
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_DataDictionaries", x => x.Id);
});
migrationBuilder.CreateTable(
name: "ScheduledTasks",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
TaskType = table.Column<string>(type: "TEXT", maxLength: 100, nullable: false),
Status = table.Column<string>(type: "TEXT", maxLength: 20, nullable: false),
Total = table.Column<int>(type: "INTEGER", nullable: false),
ConfigJson = table.Column<string>(type: "TEXT", nullable: true),
CronExpression = table.Column<string>(type: "TEXT", maxLength: 100, nullable: false),
NextRunAtUtc = table.Column<DateTime>(type: "TEXT", nullable: true),
LastRunAtUtc = table.Column<DateTime>(type: "TEXT", nullable: true),
LastBatchTaskId = table.Column<int>(type: "INTEGER", nullable: true),
OwnedAssetScopeId = table.Column<string>(type: "TEXT", maxLength: 120, nullable: true),
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ScheduledTasks", x => x.Id);
});
migrationBuilder.CreateTable(
name: "DataDictionaryItems",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
DataDictionaryId = table.Column<int>(type: "INTEGER", nullable: false),
TextValue = table.Column<string>(type: "TEXT", maxLength: 4000, nullable: true),
AssetPath = table.Column<string>(type: "TEXT", maxLength: 500, nullable: true),
FileName = table.Column<string>(type: "TEXT", maxLength: 255, nullable: true),
SortOrder = table.Column<int>(type: "INTEGER", nullable: false),
IsEnabled = table.Column<bool>(type: "INTEGER", nullable: false),
CreatedAt = table.Column<DateTime>(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");
}
/// <inheritdoc />
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");
}
}
}
@@ -439,6 +439,9 @@ namespace TelegramPanel.Data.Migrations
b.Property<DateTime>("SyncedAt")
.HasColumnType("TEXT");
b.Property<DateTime?>("SystemCreatedAtUtc")
.HasColumnType("TEXT");
b.Property<long>("TelegramId")
.HasColumnType("INTEGER");
@@ -493,6 +496,99 @@ namespace TelegramPanel.Data.Migrations
b.ToTable("ChannelGroups");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.DataDictionary", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<bool>("IsEnabled")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<int>("NextIndex")
.HasColumnType("INTEGER");
b.Property<string>("ReadMode")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("TEXT");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("TEXT");
b.Property<DateTime>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AssetPath")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("DataDictionaryId")
.HasColumnType("INTEGER");
b.Property<string>("FileName")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<bool>("IsEnabled")
.HasColumnType("INTEGER");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER");
b.Property<string>("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<int>("Id")
@@ -521,6 +617,9 @@ namespace TelegramPanel.Data.Migrations
b.Property<DateTime>("SyncedAt")
.HasColumnType("TEXT");
b.Property<DateTime?>("SystemCreatedAtUtc")
.HasColumnType("TEXT");
b.Property<long>("TelegramId")
.HasColumnType("INTEGER");
@@ -575,6 +674,63 @@ namespace TelegramPanel.Data.Migrations
b.ToTable("GroupCategories");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.ScheduledTask", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ConfigJson")
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("CronExpression")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<int?>("LastBatchTaskId")
.HasColumnType("INTEGER");
b.Property<DateTime?>("LastRunAtUtc")
.HasColumnType("TEXT");
b.Property<DateTime?>("NextRunAtUtc")
.HasColumnType("TEXT");
b.Property<string>("OwnedAssetScopeId")
.HasMaxLength(120)
.HasColumnType("TEXT");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("TEXT");
b.Property<string>("TaskType")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<int>("Total")
.HasColumnType("INTEGER");
b.Property<DateTime>("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");
@@ -12,6 +12,19 @@ public class BatchTaskRepository : Repository<BatchTask>, IBatchTaskRepository
{
}
public async Task<BatchTask?> 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<IEnumerable<BatchTask>> GetByStatusAsync(string status)
{
return await _dbSet
@@ -55,4 +68,13 @@ public class BatchTaskRepository : Repository<BatchTask>, IBatchTaskRepository
await SaveChangesWithSqliteLockRetryAsync(cancellationToken);
return staleTasks.Count;
}
private void DetachTrackedEntity(int id)
{
foreach (var entry in _context.ChangeTracker.Entries<BatchTask>())
{
if (entry.Entity.Id == id)
entry.State = EntityState.Detached;
}
}
}
@@ -0,0 +1,36 @@
using Microsoft.EntityFrameworkCore;
using TelegramPanel.Data.Entities;
namespace TelegramPanel.Data.Repositories;
/// <summary>
/// 数据字典项仓储实现。
/// </summary>
public sealed class DataDictionaryItemRepository : Repository<DataDictionaryItem>, IDataDictionaryItemRepository
{
public DataDictionaryItemRepository(AppDbContext context) : base(context)
{
}
public async Task<IReadOnlyList<DataDictionaryItem>> 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);
}
}
@@ -0,0 +1,54 @@
using Microsoft.EntityFrameworkCore;
using TelegramPanel.Data.Entities;
namespace TelegramPanel.Data.Repositories;
/// <summary>
/// 数据字典仓储实现。
/// </summary>
public sealed class DataDictionaryRepository : Repository<DataDictionary>, IDataDictionaryRepository
{
public DataDictionaryRepository(AppDbContext context) : base(context)
{
}
public override async Task<DataDictionary?> GetByIdAsync(int id)
{
return await GetWithItemsAsync(id);
}
public override async Task<IEnumerable<DataDictionary>> GetAllAsync()
{
return await GetAllWithItemsAsync();
}
public async Task<DataDictionary?> 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<DataDictionary?> 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<IReadOnlyList<DataDictionary>> 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);
}
}
@@ -7,6 +7,8 @@ namespace TelegramPanel.Data.Repositories;
/// </summary>
public interface IBatchTaskRepository : IRepository<BatchTask>
{
Task<BatchTask?> GetFreshByIdAsync(int id);
Task UpdateFreshAsync(BatchTask entity);
Task<IEnumerable<BatchTask>> GetByStatusAsync(string status);
Task<IEnumerable<BatchTask>> GetRunningTasksAsync();
Task<IEnumerable<BatchTask>> GetRecentTasksAsync(int count = 20);
@@ -0,0 +1,12 @@
using TelegramPanel.Data.Entities;
namespace TelegramPanel.Data.Repositories;
/// <summary>
/// 数据字典项仓储接口。
/// </summary>
public interface IDataDictionaryItemRepository : IRepository<DataDictionaryItem>
{
Task<IReadOnlyList<DataDictionaryItem>> GetByDictionaryIdAsync(int dictionaryId, CancellationToken cancellationToken = default);
Task DeleteByDictionaryIdAsync(int dictionaryId, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,13 @@
using TelegramPanel.Data.Entities;
namespace TelegramPanel.Data.Repositories;
/// <summary>
/// 数据字典仓储接口。
/// </summary>
public interface IDataDictionaryRepository : IRepository<DataDictionary>
{
Task<DataDictionary?> GetByNameAsync(string name, CancellationToken cancellationToken = default);
Task<DataDictionary?> GetWithItemsAsync(int id, CancellationToken cancellationToken = default);
Task<IReadOnlyList<DataDictionary>> GetAllWithItemsAsync(CancellationToken cancellationToken = default);
}
@@ -0,0 +1,12 @@
using TelegramPanel.Data.Entities;
namespace TelegramPanel.Data.Repositories;
/// <summary>
/// 计划任务仓储接口。
/// </summary>
public interface IScheduledTaskRepository : IRepository<ScheduledTask>
{
Task<IReadOnlyList<ScheduledTask>> GetAllOrderedAsync(CancellationToken cancellationToken = default);
Task<IReadOnlyList<ScheduledTask>> GetEnabledAsync(CancellationToken cancellationToken = default);
}
@@ -0,0 +1,31 @@
using Microsoft.EntityFrameworkCore;
using TelegramPanel.Data.Entities;
namespace TelegramPanel.Data.Repositories;
/// <summary>
/// 计划任务仓储实现。
/// </summary>
public sealed class ScheduledTaskRepository : Repository<ScheduledTask>, IScheduledTaskRepository
{
public ScheduledTaskRepository(AppDbContext context) : base(context)
{
}
public async Task<IReadOnlyList<ScheduledTask>> GetAllOrderedAsync(CancellationToken cancellationToken = default)
{
return await _dbSet
.OrderByDescending(x => x.CreatedAt)
.ThenByDescending(x => x.Id)
.ToListAsync(cancellationToken);
}
public async Task<IReadOnlyList<ScheduledTask>> 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);
}
}
@@ -28,6 +28,9 @@ public static class ServiceCollectionExtensions
services.AddScoped<IChannelGroupRepository, ChannelGroupRepository>();
services.AddScoped<IGroupRepository, GroupRepository>();
services.AddScoped<IGroupCategoryRepository, GroupCategoryRepository>();
services.AddScoped<IDataDictionaryRepository, DataDictionaryRepository>();
services.AddScoped<IDataDictionaryItemRepository, DataDictionaryItemRepository>();
services.AddScoped<IScheduledTaskRepository, ScheduledTaskRepository>();
services.AddScoped<IBatchTaskRepository, BatchTaskRepository>();
services.AddScoped<IBotRepository, BotRepository>();
services.AddScoped<IBotChannelRepository, BotChannelRepository>();
@@ -0,0 +1,52 @@
namespace TelegramPanel.Modules;
/// <summary>
/// 面板统一 AI 服务接口,供宿主任务与扩展模块复用。
/// </summary>
public interface ITelegramPanelAiService
{
/// <summary>
/// 根据消息文本、按钮列表与可选图片,决定下一步动作。
/// </summary>
Task<TelegramPanelAiChooseActionResult> ChooseActionAsync(
TelegramPanelAiChooseActionRequest request,
CancellationToken cancellationToken = default);
/// <summary>
/// 根据题目或上下文生成要发送的文本答案。
/// </summary>
Task<TelegramPanelAiReplyTextResult> 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<TelegramPanelAiButtonOption> 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);
@@ -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<bool> IsStillRunningAsync(CancellationToken cancellationToken);
Task UpdateProgressAsync(int completed, int failed, CancellationToken cancellationToken);
}
@@ -0,0 +1,316 @@
@namespace TelegramPanel.Web.Components.Dialogs
@inject AccountManagementService AccountManagement
@inject TelegramPanel.Core.Services.Telegram.AccountTelegramToolsService AccountTelegramTools
@inject TelegramPanel.Core.Services.AccountRiskService RiskService
@inject ImageAssetStorageService AssetStorage
@inject TemplateRenderingService TemplateRendering
@inject DataDictionaryService DictionaryService
@inject ISnackbar Snackbar
@inject IDialogService DialogService
@using Microsoft.AspNetCore.Components.Forms
@using TelegramPanel.Data.Entities
<MudDialog>
<DialogContent>
<MudStack Spacing="2">
<MudAlert Severity="Severity.Warning" Variant="Variant.Outlined" Dense="true">
将对选中的账号批量修改头像。支持固定上传单个头像,或选择图片字典变量按字典读取方式为每个账号取图。
</MudAlert>
<MudText Typo="Typo.subtitle2">账号数量:@accounts.Count</MudText>
<MudSelect T="string" @bind-Value="avatarSource" Label="头像来源" Variant="Variant.Outlined">
<MudSelectItem Value="@AvatarSourceFixed">固定上传</MudSelectItem>
<MudSelectItem Value="@AvatarSourceDictionary">图片字典变量</MudSelectItem>
</MudSelect>
@if (IsFixedSource)
{
<MudFileUpload T="IReadOnlyList<IBrowserFile>" FilesChanged="OnAvatarChanged" Accept="image/*" MaximumFileCount="1">
<ActivatorContent>
<MudButton Variant="Variant.Outlined" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Image">
选择头像图片
</MudButton>
</ActivatorContent>
</MudFileUpload>
@if (fixedAvatarBytes is { Length: > 0 })
{
<MudText Typo="Typo.body2">已选择:@fixedAvatarFileName(@fixedAvatarBytes.Length bytes)</MudText>
}
}
else
{
<MudSelect T="string" @bind-Value="selectedImageDictionaryName" Label="图片字典" Variant="Variant.Outlined">
<MudSelectItem Value="@string.Empty">-- 请选择图片字典 --</MudSelectItem>
@foreach (var item in imageDictionaryOptions)
{
<MudSelectItem Value="@item.Name">@item.DisplayText</MudSelectItem>
}
</MudSelect>
@if (imageDictionaryOptions.Count > 0)
{
<MudText Typo="Typo.caption">可用图片变量:@string.Join("、", imageDictionaryOptions.Select(x => $"{{{x.Name}}}"))</MudText>
}
}
@if (running || results.Count > 0)
{
<MudDivider Class="my-2" />
<MudStack direction="Row" AlignItems="AlignItems.Center" Spacing="2">
<MudText Typo="Typo.body2">进度:@processed / @accounts.Count</MudText>
<MudText Typo="Typo.body2" Color="Color.Success">成功:@success</MudText>
<MudText Typo="Typo.body2" Color="Color.Error">失败:@failed</MudText>
@if (running)
{
<MudProgressCircular Indeterminate="true" Size="Size.Small" />
}
</MudStack>
<MudTable Items="@results" Dense="true" Hover="true" Breakpoint="Breakpoint.Sm">
<HeaderContent>
<MudTh>账号</MudTh>
<MudTh>头像来源</MudTh>
<MudTh>结果</MudTh>
<MudTh>原因</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="账号">@context.Phone</MudTd>
<MudTd DataLabel="头像来源">@context.Source</MudTd>
<MudTd DataLabel="结果">
<MudChip T="string" Size="Size.Small" Color="@(context.Success ? Color.Success : Color.Error)">
@(context.Success ? "成功" : "失败")
</MudChip>
</MudTd>
<MudTd DataLabel="原因">@((context.Error ?? "").Trim())</MudTd>
</RowTemplate>
</MudTable>
}
</MudStack>
</DialogContent>
<DialogActions>
<MudButton Variant="Variant.Text" Color="Color.Default" OnClick="Cancel" Disabled="@running">关闭</MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="Start" Disabled="@running">
@if (running)
{
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="mr-2" />
<span>执行中...</span>
}
else
{
<span>开始修改</span>
}
</MudButton>
</DialogActions>
</MudDialog>
@code
{
private const string AvatarSourceFixed = "fixed";
private const string AvatarSourceDictionary = "dictionary";
[CascadingParameter] private MudDialogInstance MudDialog { get; set; } = default!;
[Parameter] public IReadOnlyList<int> AccountIds { get; set; } = Array.Empty<int>();
private List<Account> accounts = new();
private List<ImageDictionaryOption> imageDictionaryOptions = new();
private string avatarSource = AvatarSourceFixed;
private string selectedImageDictionaryName = string.Empty;
private byte[]? fixedAvatarBytes;
private string fixedAvatarFileName = "avatar.jpg";
private bool running;
private int processed;
private int success;
private int failed;
private readonly List<ResultRow> results = new();
private bool IsFixedSource => string.Equals(avatarSource, AvatarSourceFixed, StringComparison.OrdinalIgnoreCase);
protected override async Task OnInitializedAsync()
{
var ids = AccountIds?.Distinct().Where(x => x > 0).ToList() ?? new List<int>();
if (ids.Count == 0)
return;
var all = await AccountManagement.GetAllAccountsAsync();
accounts = all.Where(a => ids.Contains(a.Id)).OrderBy(a => a.Id).ToList();
var dictionaries = await DictionaryService.GetAllAsync();
imageDictionaryOptions = dictionaries
.Where(x => x.IsEnabled && string.Equals(x.Type, DataDictionaryTypes.Image, StringComparison.OrdinalIgnoreCase))
.OrderBy(x => x.Name, StringComparer.OrdinalIgnoreCase)
.Select(x => new ImageDictionaryOption(x.Name, GetReadModeText(x.ReadMode)))
.ToList();
}
private async Task OnAvatarChanged(IReadOnlyList<IBrowserFile> files)
{
var file = files?.FirstOrDefault();
if (file == null)
{
fixedAvatarBytes = null;
fixedAvatarFileName = "avatar.jpg";
return;
}
try
{
const long maxBytes = 20 * 1024 * 1024;
await using var stream = file.OpenReadStream(maxBytes);
using var memory = new MemoryStream();
await stream.CopyToAsync(memory);
fixedAvatarBytes = memory.ToArray();
fixedAvatarFileName = string.IsNullOrWhiteSpace(file.Name) ? "avatar.jpg" : file.Name.Trim();
avatarSource = AvatarSourceFixed;
}
catch (Exception ex)
{
fixedAvatarBytes = null;
fixedAvatarFileName = "avatar.jpg";
Snackbar.Add($"读取头像失败:{ex.Message}", Severity.Error);
}
}
private async Task Start()
{
if (running)
return;
if (accounts.Count == 0)
{
Snackbar.Add("未选择账号", Severity.Warning);
return;
}
if (IsFixedSource)
{
if (fixedAvatarBytes is not { Length: > 0 })
{
Snackbar.Add("请先上传固定头像", Severity.Warning);
return;
}
}
else if (string.IsNullOrWhiteSpace(selectedImageDictionaryName))
{
Snackbar.Add("请选择图片字典", Severity.Warning);
return;
}
var batchCheck = RiskService.CheckBatchAccounts(accounts);
if (batchCheck.HasRiskyAccounts)
{
var parameters = new DialogParameters
{
["Message"] = $"检测到 {batchCheck.RiskyCount} 个风险账号",
["DetailedMessage"] = batchCheck.GetRiskySummary(),
["ShowRecommendations"] = true,
["ShowExcludeOption"] = true,
["RiskyAccounts"] = batchCheck.RiskyAccounts.ToList()
};
var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Large };
var dialog = DialogService.Show<TelegramPanel.Web.Components.Dialogs.RiskWarningDialog>("批量操作风控警告", parameters, options);
var result = await dialog.Result;
if (result?.Canceled ?? true)
return;
if (result.Data is TelegramPanel.Core.Services.RiskWarningAction action && action == TelegramPanel.Core.Services.RiskWarningAction.ExcludeRisky)
{
accounts = batchCheck.SafeAccounts.ToList();
if (accounts.Count == 0)
{
Snackbar.Add("排除风险账号后无剩余账号", Severity.Info);
return;
}
}
}
var sourceSummary = IsFixedSource ? "固定头像" : $"图片字典变量 {{{selectedImageDictionaryName}}}";
bool? confirm = await DialogService.ShowMessageBox(
"确认修改",
$"将对 {accounts.Count} 个账号批量修改头像(来源:{sourceSummary})。是否继续?",
yesText: "继续", cancelText: "取消");
if (confirm != true)
return;
running = true;
processed = 0;
success = 0;
failed = 0;
results.Clear();
try
{
foreach (var account in accounts)
{
string source;
bool ok;
string? err;
try
{
if (IsFixedSource)
{
source = fixedAvatarFileName;
await using var stream = new MemoryStream(fixedAvatarBytes!, writable: false);
(ok, err) = await AccountTelegramTools.UpdateProfilePhotoAsync(account.Id, stream, fixedAvatarFileName, CancellationToken.None);
}
else
{
var token = $"{{{selectedImageDictionaryName}}}";
var asset = await TemplateRendering.ResolveImageTemplateAsync(token, CancellationToken.None);
source = $"{token} → {asset.FileName}";
await using var stream = await AssetStorage.OpenReadAsync(asset.AssetPath, CancellationToken.None);
(ok, err) = await AccountTelegramTools.UpdateProfilePhotoAsync(account.Id, stream, asset.FileName, CancellationToken.None);
}
}
catch (Exception ex)
{
source = IsFixedSource ? fixedAvatarFileName : $"{{{selectedImageDictionaryName}}}";
ok = false;
err = ex.Message;
}
processed++;
if (ok)
success++;
else
failed++;
results.Add(new ResultRow(account.DisplayPhone, source, ok, err));
StateHasChanged();
}
Snackbar.Add($"头像批量修改完成:成功 {success}/{accounts.Count},失败 {failed}/{accounts.Count}", failed == 0 ? Severity.Success : Severity.Warning);
}
catch (Exception ex)
{
Snackbar.Add($"执行失败:{ex.Message}", Severity.Error);
}
finally
{
running = false;
}
}
private static string GetReadModeText(string? readMode)
{
return string.Equals(readMode, DataDictionaryReadModes.Queue, StringComparison.OrdinalIgnoreCase)
? "轮询"
: "随机";
}
private void Cancel() => MudDialog.Close();
private sealed record ImageDictionaryOption(string Name, string ReadMode)
{
public string DisplayText => $"{Name}({ReadMode})";
}
private sealed record ResultRow(string Phone, string Source, bool Success, string? Error);
}
@@ -2,30 +2,41 @@
@inject AccountManagementService AccountManagement
@inject TelegramPanel.Core.Services.Telegram.AccountTelegramToolsService AccountTelegramTools
@inject TelegramPanel.Core.Services.AccountRiskService RiskService
@inject TemplateRenderingService TemplateRendering
@inject DataDictionaryService DictionaryService
@inject ISnackbar Snackbar
@inject IDialogService DialogService
@using System.Text.RegularExpressions
@using TelegramPanel.Data.Entities
<MudDialog>
<DialogContent>
<MudStack Spacing="2">
<MudAlert Severity="Severity.Warning" Variant="Variant.Outlined" Dense="true">
将对选中的账号批量修改昵称。支持两种模式:单个“批量昵称”(可自动追加手机号后 4 位),或“昵称列表”(按行轮询分配)。
将对选中的账号批量修改昵称。只需要填写“昵称模板”,支持一行一个模板,并支持多个变量组合,例如:<b>{diqu}歌神{geshou}</b>。
</MudAlert>
<MudText Typo="Typo.subtitle2">账号数量:@accounts.Count</MudText>
<MudTextField @bind-Value="baseNickname" Label="批量昵称" Variant="Variant.Outlined" Required="true"
HelperText="单昵称模式:例如“测试号”(将生成:测试号1234 / 测试号5678 ...)。若填写了“昵称列表”,则优先使用昵称列表。" />
<MudSwitch @bind-Value="appendPhoneLast4" Label="自动追加手机号后 4 位(单昵称模式)" Color="Color.Primary" />
<MudTextField @bind-Value="nicknameTemplatesText"
Label="昵称模板(换行分隔)"
Variant="Variant.Outlined"
Required="true"
Lines="6"
HelperText="每行一个昵称模板;按顺序分配给已选账号;用完后从头轮询。支持 {time} 和多个文本字典变量组合。" />
<MudTextField @bind-Value="nicknameLines" Label="昵称列表(换行分隔)" Variant="Variant.Outlined" Lines="6"
HelperText="每行一个昵称;会按顺序依次分配给已选账号;昵称用完后从头开始轮询。" />
<MudSwitch @bind-Value="appendPhoneLast4WhenDuplicate" Label="昵称重复时追加手机号后 4 位(昵称列表模式)" Color="Color.Primary" />
<MudSwitch @bind-Value="appendPhoneLast4WhenDuplicate"
Label="昵称重复时追加手机号后 4 位"
Color="Color.Primary" />
<MudText Typo="Typo.caption" Class="mud-text-secondary">
例如:昵称列表只有 1 行或轮询后重复时,为避免重复昵称可追加手机号后 4 位。
当同一批次生成出重复昵称时,自动在后面追加该账号手机号后 4 位。
</MudText>
@if (textDictionaryNames.Count > 0)
{
<MudText Typo="Typo.caption">可用文本变量:@string.Join("、", textDictionaryNames.Select(x => $"{{{x}}}"))</MudText>
}
@if (running || results.Count > 0)
{
<MudDivider Class="my-2" />
@@ -82,11 +93,13 @@
[CascadingParameter] private MudDialogInstance MudDialog { get; set; } = default!;
[Parameter] public IReadOnlyList<int> AccountIds { get; set; } = Array.Empty<int>();
private List<Account> accounts = new();
private static readonly Regex TemplateTokenRegex = new(@"\{(?<name>[a-zA-Z0-9_]+)\}", RegexOptions.Compiled);
private string baseNickname = "";
private bool appendPhoneLast4 = true;
private string nicknameLines = "";
private readonly HashSet<string> supportedTextTokens = new(StringComparer.OrdinalIgnoreCase);
private List<Account> accounts = new();
private List<string> textDictionaryNames = new();
private string nicknameTemplatesText = string.Empty;
private bool appendPhoneLast4WhenDuplicate = true;
private bool running;
@@ -103,6 +116,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()
@@ -116,16 +140,19 @@
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;
}
if (!ValidateTemplates(templates, out var templateError))
{
Snackbar.Add(templateError ?? "昵称模板不合法", Severity.Warning);
return;
}
// 批量风控检查:修改昵称是敏感操作
var batchCheck = RiskService.CheckBatchAccounts(accounts);
if (batchCheck.HasRiskyAccounts)
{
@@ -141,12 +168,11 @@
var dialog = DialogService.Show<TelegramPanel.Web.Components.Dialogs.RiskWarningDialog>("批量操作风控警告", parameters, options);
var result = await dialog.Result;
if (result.Canceled)
return; // 用户取消操作
if (result?.Canceled ?? true)
return;
if (result.Data is TelegramPanel.Core.Services.RiskWarningAction action && action == TelegramPanel.Core.Services.RiskWarningAction.ExcludeRisky)
{
// 排除风险账号后继续
accounts = batchCheck.SafeAccounts.ToList();
if (accounts.Count == 0)
{
@@ -154,7 +180,6 @@
return;
}
}
// else: 用户选择继续所有账号
}
bool? confirm = await DialogService.ShowMessageBox(
@@ -173,36 +198,57 @@
try
{
var baseUsedCount = new Dictionary<string, int>(StringComparer.Ordinal);
foreach (var a in accounts)
var usedNicknames = new Dictionary<string, int>(StringComparer.Ordinal);
foreach (var account in accounts)
{
var baseName = useList ? nicknameList[processed % nicknameList.Count] : baseNickname;
var needAppend = appendPhoneLast4;
if (useList)
var nicknameTemplate = templates[processed % templates.Count];
string resolvedNickname;
try
{
baseName = (baseName ?? string.Empty).Trim();
baseUsedCount.TryGetValue(baseName, out var used);
baseUsedCount[baseName] = used + 1;
needAppend = appendPhoneLast4WhenDuplicate && used > 0;
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 newNickname = BuildNickname(baseName, a.Phone, needAppend);
if (string.IsNullOrWhiteSpace(resolvedNickname))
{
processed++;
failed++;
results.Add(new ResultRow(account.DisplayPhone, string.Empty, false, "生成的昵称为空,请检查模板或字典内容"));
StateHasChanged();
continue;
}
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: 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 +266,37 @@
}
}
private bool ValidateTemplates(IEnumerable<string> templates, out string? error)
{
foreach (var template in templates)
{
if (!ValidateTemplate(template, out error))
return false;
}
error = null;
return true;
}
private bool ValidateTemplate(string? template, out string? error)
{
error = null;
foreach (Match token in TemplateTokenRegex.Matches(template ?? string.Empty))
{
var tokenName = token.Groups["name"].Value;
if (string.Equals(tokenName, "time", StringComparison.OrdinalIgnoreCase))
continue;
if (!supportedTextTokens.Contains(tokenName))
{
error = $"不支持的文本变量:{{{tokenName}}}。请使用 {{time}} 或已启用的文本字典变量。";
return false;
}
}
return true;
}
private static string BuildNickname(string baseName, string phone, bool appendLast4)
{
var name = (baseName ?? string.Empty).Trim();
@@ -266,4 +343,3 @@
private sealed record ResultRow(string Phone, string NewNickname, bool Success, string? Error);
}
@@ -2,9 +2,10 @@
@inject AccountManagementService AccountManagement
@inject TelegramPanel.Core.Services.Telegram.AccountTelegramToolsService AccountTelegramTools
@inject TelegramPanel.Core.Services.AccountRiskService RiskService
@inject TemplateRenderingService TemplateRendering
@inject DataDictionaryService DictionaryService
@inject ISnackbar Snackbar
@inject IDialogService DialogService
@using System.Security.Cryptography
@using System.Text.RegularExpressions
@using TelegramPanel.Data.Entities
@@ -12,19 +13,18 @@
<DialogContent>
<MudStack Spacing="2">
<MudAlert Severity="Severity.Warning" Variant="Variant.Outlined" Dense="true">
将对选中的账号批量修改用户名(t.me/xxx)。用户名模板支持变量:
<MudText Typo="Typo.body2" Class="mt-1">
<b>{number}</b> / <b>{letter}</b> 表示 1 位随机数字/字母;<b>{number3}</b> 表示 3 位随机数字;<b>{letter4}</b> 表示 4 位随机字母。可组合使用。
</MudText>
<MudText Typo="Typo.body2" Class="mt-1">
示例:<b>test_{number3}{letter4}</b> → <b>test_123abcd</b>
</MudText>
将对选中的账号批量修改用户名(t.me/xxx)。用户名模板支持 `{time}` 和文本字典变量;批量执行时建议使用文本字典提供不同值,避免重复。
</MudAlert>
<MudText Typo="Typo.subtitle2">账号数量:@accounts.Count</MudText>
<MudTextField @bind-Value="usernameTemplate" Label="用户名模板" Variant="Variant.Outlined" Required="true"
HelperText="请输入用户名模板(不要带开头的 at 符号)。例如:test_{number3}{letter4}" />
HelperText="请输入用户名模板(不要带开头的 at 符号)。例如:tg_{city}_{time}" />
@if (textDictionaryNames.Count > 0)
{
<MudText Typo="Typo.caption">可用文本变量:@string.Join("、", textDictionaryNames.Select(x => $"{{{x}}}"))</MudText>
}
@if (running || results.Count > 0)
{
@@ -82,11 +82,12 @@
[CascadingParameter] private MudDialogInstance MudDialog { get; set; } = default!;
[Parameter] public IReadOnlyList<int> AccountIds { get; set; } = Array.Empty<int>();
private static readonly Regex SupportedTokenRegex = new(@"\{(?<kind>number|nuber|letter)(?<len>\d*)\}", RegexOptions.Compiled | RegexOptions.IgnoreCase);
private static readonly Regex AnyTokenRegex = new(@"\{[^}]*\}", RegexOptions.Compiled);
private static readonly Regex TemplateTokenRegex = new(@"\{(?<name>[a-zA-Z0-9_]+)\}", RegexOptions.Compiled);
private static readonly Regex UsernameRegex = new(@"^[A-Za-z][A-Za-z0-9_]{4,31}$", RegexOptions.Compiled);
private readonly HashSet<string> supportedTextTokens = new(StringComparer.OrdinalIgnoreCase);
private List<Account> accounts = new();
private List<string> textDictionaryNames = new();
private string usernameTemplate = "";
private bool running;
@@ -103,6 +104,17 @@
var all = await AccountManagement.GetAllAccountsAsync();
accounts = all.Where(a => ids.Contains(a.Id)).OrderBy(a => a.Id).ToList();
var dictionaries = await DictionaryService.GetAllAsync();
textDictionaryNames = dictionaries
.Where(x => x.IsEnabled && string.Equals(x.Type, DataDictionaryTypes.Text, StringComparison.OrdinalIgnoreCase))
.Select(x => x.Name)
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
.ToList();
supportedTextTokens.Clear();
foreach (var item in textDictionaryNames)
supportedTextTokens.Add(item);
}
private async Task Start()
@@ -123,19 +135,18 @@
return;
}
if (accounts.Count > 1 && !SupportedTokenRegex.IsMatch(template))
if (accounts.Count > 1 && !TemplateTokenRegex.IsMatch(template))
{
Snackbar.Add("批量修改多个账号时,用户名模板需要包含至少一个随机变量(例如 {number3} / {letter4})", Severity.Warning);
Snackbar.Add("批量修改多个账号时,用户名模板至少需要包含一个变量(例如 {time} 或 {username_pool})", Severity.Warning);
return;
}
if (!ValidateTemplateTokens(template, out var templateErr))
if (!ValidateTemplate(template, out var templateErr))
{
Snackbar.Add(templateErr ?? "用户名模板不合法", Severity.Warning);
return;
}
// 批量风控检查:修改用户名是敏感操作
var batchCheck = RiskService.CheckBatchAccounts(accounts);
if (batchCheck.HasRiskyAccounts)
{
@@ -151,7 +162,7 @@
var dialog = DialogService.Show<TelegramPanel.Web.Components.Dialogs.RiskWarningDialog>("批量操作风控警告", parameters, options);
var result = await dialog.Result;
if (result.Canceled)
if (result?.Canceled ?? true)
return;
if (result.Data is TelegramPanel.Core.Services.RiskWarningAction action && action == TelegramPanel.Core.Services.RiskWarningAction.ExcludeRisky)
@@ -182,30 +193,48 @@
try
{
var generated = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var a in accounts)
foreach (var account in accounts)
{
if (!TryGenerateUniqueUsername(template, generated, out var newUsername, out var genErr))
string renderedTemplate;
try
{
renderedTemplate = await TemplateRendering.RenderTextTemplateAsync(template, CancellationToken.None);
}
catch (Exception ex)
{
processed++;
failed++;
results.Add(new ResultRow(a.DisplayPhone, "", false, genErr ?? "生成用户名失败"));
results.Add(new ResultRow(account.DisplayPhone, string.Empty, false, $"用户名模板解析失败:{ex.Message}"));
StateHasChanged();
continue;
}
if (!TryPrepareUsername(renderedTemplate, generated, out var newUsername, out var prepareError))
{
processed++;
failed++;
results.Add(new ResultRow(account.DisplayPhone, newUsername, false, prepareError ?? "生成用户名失败"));
StateHasChanged();
continue;
}
var (ok, err, username) = await AccountTelegramTools.UpdateUsernameAsync(
accountId: a.Id,
accountId: account.Id,
username: newUsername,
cancellationToken: CancellationToken.None);
processed++;
if (ok) success++; else failed++;
results.Add(new ResultRow(a.DisplayPhone, newUsername, ok, err));
if (ok)
success++;
else
failed++;
results.Add(new ResultRow(account.DisplayPhone, newUsername, ok, err));
if (ok)
{
a.Username = username;
await AccountManagement.UpdateAccountAsync(a);
account.Username = username;
await AccountManagement.UpdateAccountAsync(account);
}
StateHasChanged();
@@ -223,27 +252,18 @@
}
}
private static bool ValidateTemplateTokens(string template, out string? error)
private bool ValidateTemplate(string? template, out string? error)
{
error = null;
var tokens = AnyTokenRegex.Matches(template);
foreach (Match t in tokens)
foreach (Match token in TemplateTokenRegex.Matches(template ?? string.Empty))
{
if (!SupportedTokenRegex.IsMatch(t.Value))
{
error = $"不支持的变量:{t.Value}。仅支持 {{number}}/{{letter}}/{{numberN}}/{{letterN}}。";
return false;
}
var m = SupportedTokenRegex.Match(t.Value);
var lenStr = m.Groups["len"].Value;
if (string.IsNullOrWhiteSpace(lenStr))
var tokenName = token.Groups["name"].Value;
if (string.Equals(tokenName, "time", StringComparison.OrdinalIgnoreCase))
continue;
if (!int.TryParse(lenStr, out var len) || len <= 0 || len > 32)
if (!supportedTextTokens.Contains(tokenName))
{
error = $"变量长度不合法:{t.Value}。长度需为 1-32。";
error = $"不支持的文本变量:{{{tokenName}}}。请使用 {{time}} 或已启用的文本字典变量。";
return false;
}
}
@@ -251,67 +271,28 @@
return true;
}
private static bool TryGenerateUniqueUsername(
string template,
private static bool TryPrepareUsername(
string templateResult,
HashSet<string> reserved,
out string username,
out string? error)
{
username = "";
username = (templateResult ?? string.Empty).Trim().TrimStart('@');
error = null;
for (var i = 0; i < 25; i++)
if (!UsernameRegex.IsMatch(username))
{
var candidate = ExpandTemplate(template);
candidate = (candidate ?? string.Empty).Trim().TrimStart('@');
if (!UsernameRegex.IsMatch(candidate))
{
error = "生成的用户名不符合 Telegram 规则:仅允许字母/数字/下划线,长度 5-32,且必须以字母开头。";
return false;
}
if (reserved.Add(candidate))
{
username = candidate;
return true;
}
error = "生成的用户名不符合 Telegram 规则:仅允许字母/数字/下划线,长度 5-32,且必须以字母开头。";
return false;
}
error = "生成的用户名在本次批量操作中重复次数过多,请增加随机位数(例如 {number6} / {letter6})。";
return false;
}
private static string ExpandTemplate(string template)
{
return SupportedTokenRegex.Replace(template, m =>
if (!reserved.Add(username))
{
var kind = (m.Groups["kind"].Value ?? string.Empty).ToLowerInvariant();
if (kind == "nuber")
kind = "number";
var lenStr = m.Groups["len"].Value;
var len = string.IsNullOrWhiteSpace(lenStr) ? 1 : int.Parse(lenStr);
error = "本次批量操作生成了重复用户名,请调整文本字典内容或模板。";
return false;
}
return kind == "number"
? RandomDigits(len)
: RandomLetters(len);
});
}
private static string RandomDigits(int length)
{
var chars = new char[length];
for (var i = 0; i < length; i++)
chars[i] = (char)('0' + RandomNumberGenerator.GetInt32(0, 10));
return new string(chars);
}
private static string RandomLetters(int length)
{
var chars = new char[length];
for (var i = 0; i < length; i++)
chars[i] = (char)('a' + RandomNumberGenerator.GetInt32(0, 26));
return new string(chars);
return true;
}
private void Cancel() => MudDialog.Close();
@@ -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
<MudStack Spacing="2">
@if (loading)
{
<MudProgressCircular Indeterminate="true" Size="Size.Small" Color="Color.Primary" />
<MudText Typo="Typo.caption">正在加载配置项...</MudText>
}
else
{
<MudSelect T="int"
Label="执行账号分类(可多选)"
Variant="Variant.Outlined"
MultiSelection="true"
SelectedValues="selectedCategoryIds"
SelectedValuesChanged="OnSelectedCategoriesChanged">
@foreach (var item in categories)
{
<MudSelectItem Value="@item.Id">@item.Name</MudSelectItem>
}
</MudSelect>
<MudSelect T="string" @bind-Value="createType" @bind-Value:after="OnInputsChanged" Label="创建对象类型" Variant="Variant.Outlined">
<MudSelectItem Value="@ChannelGroupAutomationTaskObjectTypes.Channel">频道</MudSelectItem>
<MudSelectItem Value="@ChannelGroupAutomationTaskObjectTypes.Group">群组</MudSelectItem>
</MudSelect>
@if (IsChannel)
{
<MudSelect T="int" @bind-Value="selectedChannelGroupId" @bind-Value:after="OnInputsChanged" Label="频道分组" Variant="Variant.Outlined">
<MudSelectItem Value="0">未分组</MudSelectItem>
@foreach (var item in channelGroups)
{
<MudSelectItem Value="@item.Id">@item.Name</MudSelectItem>
}
</MudSelect>
}
else
{
<MudSelect T="int" @bind-Value="selectedGroupCategoryId" @bind-Value:after="OnInputsChanged" Label="群组分类" Variant="Variant.Outlined">
<MudSelectItem Value="0">未分类</MudSelectItem>
@foreach (var item in groupCategories)
{
<MudSelectItem Value="@item.Id">@item.Name</MudSelectItem>
}
</MudSelect>
}
<MudGrid>
<MudItem xs="12" md="6">
<MudNumericField T="int" @bind-Value="systemCreatedLimit" @bind-Value:after="OnInputsChanged" Label="系统创建数量上限" Variant="Variant.Outlined" Min="1" Max="100000" />
</MudItem>
<MudItem xs="12" md="6">
<MudNumericField T="int" @bind-Value="perAccountBatchSize" @bind-Value:after="OnInputsChanged" Label="每账号本轮处理数量" Variant="Variant.Outlined" Min="1" Max="1000" />
</MudItem>
</MudGrid>
<MudTextField @bind-Value="titleTemplate" @bind-Value:after="OnInputsChanged"
Label="标题模板" Variant="Variant.Outlined"
HelperText="支持 {time} 和文本字典变量,例如:临时频道{time}" />
@if (textDictionaryNames.Count > 0)
{
<MudText Typo="Typo.caption">可用文本变量:@string.Join("、", textDictionaryNames.Select(x => $"{{{x}}}"))</MudText>
}
<MudSelect T="string" @bind-Value="avatarSource" @bind-Value:after="OnInputsChanged" Label="头像来源" Variant="Variant.Outlined">
<MudSelectItem Value="@ChannelGroupAutomationAvatarSourceModes.None">不设置</MudSelectItem>
<MudSelectItem Value="@ChannelGroupAutomationAvatarSourceModes.Fixed">固定上传</MudSelectItem>
<MudSelectItem Value="@ChannelGroupAutomationAvatarSourceModes.Dictionary">图片字典变量</MudSelectItem>
</MudSelect>
@if (avatarSource == ChannelGroupAutomationAvatarSourceModes.Fixed)
{
<MudFileUpload T="IReadOnlyList<IBrowserFile>" FilesChanged="OnAvatarChanged" Accept="image/*" MaximumFileCount="1">
<ActivatorContent>
<MudButton Variant="Variant.Outlined" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Image">上传固定头像</MudButton>
</ActivatorContent>
</MudFileUpload>
@if (!string.IsNullOrWhiteSpace(fixedAvatarAssetPath))
{
<MudText Typo="Typo.caption">已保存头像:@fixedAvatarAssetPath</MudText>
}
}
else if (avatarSource == ChannelGroupAutomationAvatarSourceModes.Dictionary)
{
<MudSelect T="string" @bind-Value="selectedImageDictionaryName" @bind-Value:after="OnSelectedImageDictionaryChanged" Label="图片字典" Variant="Variant.Outlined">
<MudSelectItem Value="@string.Empty">-- 请选择图片字典 --</MudSelectItem>
@foreach (var item in imageDictionaryNames)
{
<MudSelectItem Value="@item">@item</MudSelectItem>
}
</MudSelect>
}
<MudGrid>
<MudItem xs="12" md="4">
<MudNumericField T="int" @bind-Value="minDelaySeconds" @bind-Value:after="OnInputsChanged" Label="最小间隔(秒)" Variant="Variant.Outlined" Min="0" Max="3600" />
</MudItem>
<MudItem xs="12" md="4">
<MudNumericField T="int" @bind-Value="maxDelaySeconds" @bind-Value:after="OnInputsChanged" Label="最大间隔(秒)" Variant="Variant.Outlined" Min="0" Max="3600" />
</MudItem>
<MudItem xs="12" md="4">
<MudNumericField T="int" @bind-Value="jitterPercent" @bind-Value:after="OnInputsChanged" Label="时间抖动(%)" Variant="Variant.Outlined" Min="0" Max="100" />
</MudItem>
</MudGrid>
}
</MudStack>
@code {
[Parameter] public ModuleTaskDraft Draft { get; set; }
[Parameter] public EventCallback<ModuleTaskDraft> DraftChanged { get; set; }
[Parameter] public string? InitialConfigJson { get; set; }
private bool loading = true;
private bool initialized;
private List<AccountCategory> categories = new();
private List<Account> activeAccounts = new();
private List<ChannelGroup> channelGroups = new();
private List<GroupCategory> groupCategories = new();
private List<string> textDictionaryNames = new();
private List<string> imageDictionaryNames = new();
private IEnumerable<int> selectedCategoryIds = Array.Empty<int>();
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<ChannelGroupPrivateCreateTaskConfig>(InitialConfigJson!);
if (config == null)
return;
selectedCategoryIds = (config.CategoryIds ?? new List<int>()).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<int> values)
{
selectedCategoryIds = (values ?? Array.Empty<int>()).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<IBrowserFile> 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;
}
}
@@ -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
<MudStack Spacing="2">
@if (loading)
{
<MudProgressCircular Indeterminate="true" Size="Size.Small" Color="Color.Primary" />
<MudText Typo="Typo.caption">正在加载配置项...</MudText>
}
else
{
<MudSelect T="int"
Label="执行账号分类(可多选)"
Variant="Variant.Outlined"
MultiSelection="true"
SelectedValues="selectedCategoryIds"
SelectedValuesChanged="OnSelectedCategoriesChanged">
@foreach (var item in categories)
{
<MudSelectItem Value="@item.Id">@item.Name</MudSelectItem>
}
</MudSelect>
<MudSelect T="string" @bind-Value="targetType" @bind-Value:after="OnInputsChanged" Label="处理对象类型" Variant="Variant.Outlined">
<MudSelectItem Value="@ChannelGroupAutomationTaskObjectTypes.Channel">频道</MudSelectItem>
<MudSelectItem Value="@ChannelGroupAutomationTaskObjectTypes.Group">群组</MudSelectItem>
</MudSelect>
@if (IsChannel)
{
<MudSelect T="int" @bind-Value="selectedChannelGroupId" @bind-Value:after="OnInputsChanged" Label="来源频道分组" Variant="Variant.Outlined">
<MudSelectItem Value="0">未分组</MudSelectItem>
@foreach (var item in channelGroups)
{
<MudSelectItem Value="@item.Id">@item.Name</MudSelectItem>
}
</MudSelect>
}
else
{
<MudSelect T="int" @bind-Value="selectedGroupCategoryId" @bind-Value:after="OnInputsChanged" Label="来源群组分类" Variant="Variant.Outlined">
<MudSelectItem Value="0">未分类</MudSelectItem>
@foreach (var item in groupCategories)
{
<MudSelectItem Value="@item.Id">@item.Name</MudSelectItem>
}
</MudSelect>
}
<MudGrid>
<MudItem xs="12" md="4">
<MudNumericField T="int" @bind-Value="minSystemCreatedDays" @bind-Value:after="OnInputsChanged" Label="最小系统创建天数" Variant="Variant.Outlined" Min="0" Max="3650" />
</MudItem>
<MudItem xs="12" md="4">
<MudNumericField T="int" @bind-Value="maxPublicCount" @bind-Value:after="OnInputsChanged" Label="保持公开最大数量" Variant="Variant.Outlined" Min="1" Max="1000" />
</MudItem>
<MudItem xs="12" md="4">
<MudNumericField T="int" @bind-Value="perAccountBatchSize" @bind-Value:after="OnInputsChanged" Label="每账号本轮处理数量" Variant="Variant.Outlined" Min="1" Max="1000" />
</MudItem>
</MudGrid>
<MudTextField @bind-Value="titleTemplate" @bind-Value:after="OnInputsChanged"
Label="标题模板" Variant="Variant.Outlined"
HelperText="支持 {time} 和文本字典变量" />
<MudTextField @bind-Value="descriptionTemplate" @bind-Value:after="OnInputsChanged"
Label="描述模板" Variant="Variant.Outlined"
Lines="3"
HelperText="可留空,留空则保持原描述不变" />
<MudTextField @bind-Value="usernameTemplate" @bind-Value:after="OnInputsChanged"
Label="公开用户名模板" Variant="Variant.Outlined"
HelperText="支持变量,最终结果会按 Telegram 用户名规则校验" />
@if (textDictionaryNames.Count > 0)
{
<MudText Typo="Typo.caption">可用文本变量:@string.Join("、", textDictionaryNames.Select(x => $"{{{x}}}"))</MudText>
}
<MudSelect T="string" @bind-Value="avatarSource" @bind-Value:after="OnInputsChanged" Label="头像来源" Variant="Variant.Outlined">
<MudSelectItem Value="@ChannelGroupAutomationAvatarSourceModes.None">不设置</MudSelectItem>
<MudSelectItem Value="@ChannelGroupAutomationAvatarSourceModes.Fixed">固定上传</MudSelectItem>
<MudSelectItem Value="@ChannelGroupAutomationAvatarSourceModes.Dictionary">图片字典变量</MudSelectItem>
</MudSelect>
@if (avatarSource == ChannelGroupAutomationAvatarSourceModes.Fixed)
{
<MudFileUpload T="IReadOnlyList<IBrowserFile>" FilesChanged="OnAvatarChanged" Accept="image/*" MaximumFileCount="1">
<ActivatorContent>
<MudButton Variant="Variant.Outlined" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Image">上传固定头像</MudButton>
</ActivatorContent>
</MudFileUpload>
@if (!string.IsNullOrWhiteSpace(fixedAvatarAssetPath))
{
<MudText Typo="Typo.caption">已保存头像:@fixedAvatarAssetPath</MudText>
}
}
else if (avatarSource == ChannelGroupAutomationAvatarSourceModes.Dictionary)
{
<MudSelect T="string" @bind-Value="selectedImageDictionaryName" @bind-Value:after="OnSelectedImageDictionaryChanged" Label="图片字典" Variant="Variant.Outlined">
<MudSelectItem Value="@string.Empty">-- 请选择图片字典 --</MudSelectItem>
@foreach (var item in imageDictionaryNames)
{
<MudSelectItem Value="@item">@item</MudSelectItem>
}
</MudSelect>
}
<MudGrid>
<MudItem xs="12" md="4">
<MudNumericField T="int" @bind-Value="minDelaySeconds" @bind-Value:after="OnInputsChanged" Label="最小间隔(秒)" Variant="Variant.Outlined" Min="0" Max="3600" />
</MudItem>
<MudItem xs="12" md="4">
<MudNumericField T="int" @bind-Value="maxDelaySeconds" @bind-Value:after="OnInputsChanged" Label="最大间隔(秒)" Variant="Variant.Outlined" Min="0" Max="3600" />
</MudItem>
<MudItem xs="12" md="4">
<MudNumericField T="int" @bind-Value="jitterPercent" @bind-Value:after="OnInputsChanged" Label="时间抖动(%)" Variant="Variant.Outlined" Min="0" Max="100" />
</MudItem>
</MudGrid>
}
</MudStack>
@code {
[Parameter] public ModuleTaskDraft Draft { get; set; }
[Parameter] public EventCallback<ModuleTaskDraft> DraftChanged { get; set; }
[Parameter] public string? InitialConfigJson { get; set; }
private bool loading = true;
private bool initialized;
private List<AccountCategory> categories = new();
private List<Account> activeAccounts = new();
private List<ChannelGroup> channelGroups = new();
private List<GroupCategory> groupCategories = new();
private List<string> textDictionaryNames = new();
private List<string> imageDictionaryNames = new();
private IEnumerable<int> selectedCategoryIds = Array.Empty<int>();
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<ChannelGroupPublicizeTaskConfig>(InitialConfigJson!);
if (config == null)
return;
selectedCategoryIds = (config.CategoryIds ?? new List<int>()).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<int> values)
{
selectedCategoryIds = (values ?? Array.Empty<int>()).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<IBrowserFile> 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;
}
}
@@ -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
<MudDialog>
<DialogContent>
<MudStack Spacing="2">
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Dense="true">
任务会提交到后台执行,可在【任务中心】查看进度与结果。
立即执行会创建一条后台执行记录;Cron 计划会在任务中心的【计划任务】区域持续调度。
</MudAlert>
<MudGrid>
<MudItem xs="12" md="6">
<MudSelect @bind-Value="selectedCategory" Label="任务分类" Variant="Variant.Outlined" Dense="true">
@foreach (var c in AvailableCategories)
{
<MudSelectItem Value="@c">@GetCategoryDisplayName(c)</MudSelectItem>
}
</MudSelect>
<MudSelect T="string" @bind-Value="selectedCategory" Label="任务分类" Variant="Variant.Outlined" Dense="true">
@foreach (var c in AvailableCategories)
{
<MudSelectItem Value="@c">@GetCategoryDisplayName(c)</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="12" md="6">
<MudSelect @bind-Value="selectedTaskType" Label="任务类型" Variant="Variant.Outlined" Dense="true">
<MudSelect T="string" @bind-Value="selectedTaskType" Label="任务类型" Variant="Variant.Outlined" Dense="true">
@foreach (var opt in AvailableOptions)
{
<MudSelectItem Value="@opt.TaskType">@opt.DisplayName</MudSelectItem>
@@ -34,11 +38,38 @@
</MudItem>
</MudGrid>
@if (CurrentOption != null && !string.IsNullOrWhiteSpace(CurrentOption.Description))
<MudRadioGroup T="string" @bind-Value="submissionMode">
<MudRadio T="string" Value="@SubmissionModes.Once">立即执行</MudRadio>
<MudRadio T="string" Value="@SubmissionModes.Scheduled">Cron 计划</MudRadio>
</MudRadioGroup>
@if (!string.IsNullOrWhiteSpace(CurrentOption?.Description))
{
<MudText Typo="Typo.caption" Class="mud-text-secondary">@CurrentOption.Description</MudText>
}
@if (IsScheduledMode)
{
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Dense="true">
Cron 按服务器本地时区解析:<b>@TimeZoneInfo.Local.Id</b>
</MudAlert>
<MudTextField @bind-Value="cronExpression"
Label="Cron 表达式"
Variant="Variant.Outlined"
Immediate="true"
HelperText="5 段格式:分 时 日 月 周,例如:0 9 * * *" />
<MudStack Row="true" Spacing="1" Wrap="Wrap.Wrap">
@foreach (var preset in CronPresets)
{
<MudButton Variant="Variant.Outlined" Size="Size.Small" OnClick="@(() => ApplyCronPreset(preset.Expression))">
@preset.Label
</MudButton>
}
</MudStack>
}
@if (CurrentOption != null && !string.IsNullOrWhiteSpace(CurrentOption.CreateRoute))
{
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Dense="true">
@@ -56,7 +87,7 @@
else if (CurrentOption != null && !string.IsNullOrWhiteSpace(CurrentOption.EditorComponentType) && editorComponentType != null)
{
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Dense="true">
该任务由模块提供自定义创建器。
该任务由模块提供自定义配置表单。
</MudAlert>
<DynamicComponent Type="editorComponentType" Parameters="editorParameters" />
@@ -72,10 +103,18 @@
该任务类型暂无专用创建表单。你可以填写通用配置(JSON 字符串)提交任务;对应模块需要在后台实现该 TaskType 的执行器。
</MudAlert>
<MudTextField @bind-Value="genericConfigJson" Label="任务配置(JSON)" Variant="Variant.Outlined" Lines="8"
<MudTextField @bind-Value="genericConfigJson"
Label="任务配置(JSON)"
Variant="Variant.Outlined"
Lines="8"
HelperText="模块可自行解析该 JSON。若无需配置可留空。" />
<MudNumericField @bind-Value="genericTotal" Label="总数(Total,可选)" Variant="Variant.Outlined" Min="0" Max="1000000"
<MudNumericField T="int"
@bind-Value="genericTotal"
Label="总数(Total,可选)"
Variant="Variant.Outlined"
Min="0"
Max="1000000"
HelperText="用于进度条展示;若不确定可填 0,由执行器在运行时持续更新进度。" />
}
</MudStack>
@@ -88,6 +127,10 @@
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="mr-2" />
<span>提交中...</span>
}
else if (IsScheduledMode)
{
<span>保存计划</span>
}
else
{
<span>提交任务</span>
@@ -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<CronPresetItem> 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<string, object?> editorParameters = new();
private bool IsScheduledMode => string.Equals(submissionMode, SubmissionModes.Scheduled, StringComparison.OrdinalIgnoreCase);
private IEnumerable<string> 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<TelegramPanel.Modules.ModuleTaskDefinition> AvailableOptions =>
private IEnumerable<ModuleTaskDefinition> 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<string, object?>();
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<string, object?>
{
["Draft"] = editorDraft,
["DraftChanged"] = EventCallback.Factory.Create<TelegramPanel.Modules.ModuleTaskDraft>(this, OnEditorDraftChanged)
["DraftChanged"] = EventCallback.Factory.Create<ModuleTaskDraft>(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];
@@ -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
<MudDialog>
<DialogContent>
@if (loading)
{
<MudStack Spacing="2">
<MudProgressCircular Indeterminate="true" Size="Size.Small" Color="Color.Primary" />
<MudText Typo="Typo.caption">正在加载字典配置...</MudText>
</MudStack>
}
else if (!string.IsNullOrWhiteSpace(loadError))
{
<MudAlert Severity="Severity.Error" Variant="Variant.Outlined" Dense="true">
@loadError
</MudAlert>
}
else
{
<MudStack Spacing="2">
<MudGrid>
<MudItem xs="12" md="6">
<MudTextField @bind-Value="name"
Label="字典名称"
Variant="Variant.Outlined"
HelperText="变量名使用 {name} 引用;建议使用字母、数字、下划线。" />
</MudItem>
<MudItem xs="12" md="6">
<MudTextField @bind-Value="displayName"
Label="显示名称"
Variant="Variant.Outlined" />
</MudItem>
</MudGrid>
<MudTextField @bind-Value="description"
Label="描述"
Variant="Variant.Outlined"
Lines="2" />
<MudGrid>
<MudItem xs="12" md="4">
<MudSelect T="string" @bind-Value="type" Label="字典类型" Variant="Variant.Outlined" Disabled="@IsEdit">
<MudSelectItem Value="@DataDictionaryTypes.Text">文本字典</MudSelectItem>
<MudSelectItem Value="@DataDictionaryTypes.Image">图片字典</MudSelectItem>
</MudSelect>
</MudItem>
<MudItem xs="12" md="4">
<MudSelect T="string" @bind-Value="readMode" Label="读取模式" Variant="Variant.Outlined">
<MudSelectItem Value="@DataDictionaryReadModes.Random">随机</MudSelectItem>
<MudSelectItem Value="@DataDictionaryReadModes.Queue">队列轮询</MudSelectItem>
</MudSelect>
</MudItem>
<MudItem xs="12" md="4">
<MudSwitch @bind-Value="isEnabled" Color="Color.Primary">启用字典</MudSwitch>
</MudItem>
</MudGrid>
@if (IsTextDictionary)
{
<MudTextField @bind-Value="textLines"
Label="文本内容"
Variant="Variant.Outlined"
Lines="10"
HelperText="一行一条内容,保存时会自动忽略空行。" />
}
else
{
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Dense="true">
图片字典支持一次上传多张图;删除字典或删除字典项时会同步清理图片文件。
</MudAlert>
<MudFileUpload T="IReadOnlyList<IBrowserFile>"
FilesChanged="OnFilesChanged"
Accept="image/*"
MaximumFileCount="20">
<ActivatorContent>
<MudButton Variant="Variant.Outlined" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Image">
选择图片
</MudButton>
</ActivatorContent>
</MudFileUpload>
@if (existingImages.Count > 0)
{
<MudText Typo="Typo.subtitle2">现有图片</MudText>
<MudTable Items="@existingImages" Hover="true" Dense="true">
<HeaderContent>
<MudTh>文件名</MudTh>
<MudTh>预览</MudTh>
<MudTh>操作</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="文件名">@context.FileName</MudTd>
<MudTd DataLabel="预览"><a href="@GetImageUrl(context.AssetPath)" target="_blank">查看</a></MudTd>
<MudTd DataLabel="操作">
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error" OnClick="@(() => RemoveExistingImage(context.Id))" />
</MudTd>
</RowTemplate>
</MudTable>
}
@if (newImageFiles.Count > 0)
{
<MudText Typo="Typo.subtitle2">待上传图片</MudText>
<MudTable Items="@GetPendingImageRows()" Hover="true" Dense="true">
<HeaderContent>
<MudTh>文件名</MudTh>
<MudTh>大小</MudTh>
<MudTh>操作</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="文件名">@context.FileName</MudTd>
<MudTd DataLabel="大小">@FormatBytes(context.Size)</MudTd>
<MudTd DataLabel="操作">
<MudIconButton Icon="@Icons.Material.Filled.Close" Size="Size.Small" Color="Color.Default" OnClick="@(() => RemovePendingImage(context.Index))" />
</MudTd>
</RowTemplate>
</MudTable>
}
}
</MudStack>
}
</DialogContent>
<DialogActions>
<MudButton Variant="Variant.Text" Color="Color.Default" OnClick="Cancel" Disabled="@saving">关闭</MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="Save" Disabled="@saving">
@if (saving)
{
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="mr-2" />
<span>保存中...</span>
}
else
{
<span>保存字典</span>
}
</MudButton>
</DialogActions>
</MudDialog>
@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<ExistingImageRow> existingImages = new();
private List<IBrowserFile> 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<IBrowserFile> 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<PendingImageRow> 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<string>();
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<DataDictionaryImageItemInput>();
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();
}
@@ -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();
@@ -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
<MudDialog>
<DialogContent>
@if (loading)
{
<MudStack Spacing="2">
<MudProgressCircular Indeterminate="true" Size="Size.Small" Color="Color.Primary" />
<MudText Typo="Typo.caption">正在加载计划任务配置...</MudText>
</MudStack>
}
else if (!string.IsNullOrWhiteSpace(loadError))
{
<MudAlert Severity="Severity.Error" Variant="Variant.Outlined" Dense="true">
@loadError
</MudAlert>
}
else
{
<MudStack Spacing="2">
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Dense="true">
Cron 按服务器本地时区解析:<b>@TimeZoneInfo.Local.Id</b>。
</MudAlert>
<MudTextField @bind-Value="cronExpression"
Label="Cron 表达式"
Variant="Variant.Outlined"
Immediate="true"
HelperText="5 段格式:分 时 日 月 周,例如:*/30 * * * *" />
<MudStack Row="true" Spacing="1" Wrap="Wrap.Wrap">
@foreach (var preset in CronPresets)
{
<MudButton Variant="Variant.Outlined" Size="Size.Small" OnClick="@(() => ApplyCronPreset(preset.Expression))">
@preset.Label
</MudButton>
}
</MudStack>
@if (editorComponentType != null)
{
<DynamicComponent Type="editorComponentType" Parameters="editorParameters" />
@if (!string.IsNullOrWhiteSpace(draft.ValidationError))
{
<MudText Typo="Typo.caption" Color="Color.Error">@draft.ValidationError</MudText>
}
}
else if (!hasDedicatedCreateRoute)
{
<MudTextField @bind-Value="genericConfigJson"
Label="任务配置(JSON)"
Variant="Variant.Outlined"
Lines="8"
HelperText="无专用编辑器时,可直接编辑 JSON 配置。" />
<MudNumericField T="int"
@bind-Value="genericTotal"
Label="总数(Total)"
Variant="Variant.Outlined"
Min="0"
Max="1000000" />
}
else
{
<MudAlert Severity="Severity.Warning" Variant="Variant.Outlined" Dense="true">
该任务使用独立页面创建,当前不支持在此窗口编辑配置。
</MudAlert>
}
</MudStack>
}
</DialogContent>
<DialogActions>
<MudButton Variant="Variant.Text" Color="Color.Default" OnClick="Cancel" Disabled="@saving">关闭</MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="Save" Disabled="@(saving || !CanSave)">
@if (saving)
{
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="mr-2" />
<span>保存中...</span>
}
else
{
<span>保存计划任务</span>
}
</MudButton>
</DialogActions>
</MudDialog>
@code {
private sealed record CronPresetItem(string Label, string Expression);
private static readonly IReadOnlyList<CronPresetItem> 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<string, object?> 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<string, object?> BuildEditorParameters(Type componentType, ScheduledTask task)
{
var parameters = new Dictionary<string, object?>
{
["Draft"] = draft,
["DraftChanged"] = EventCallback.Factory.Create<ModuleTaskDraft>(this, OnDraftChanged)
};
AddOptionalParameter(parameters, componentType, "InitialConfigJson", task.ConfigJson);
AddOptionalParameter(parameters, componentType, "TaskId", task.Id);
return parameters;
}
private static void AddOptionalParameter(Dictionary<string, object?> 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();
}
@@ -1,4 +1,7 @@
@inject AccountCategoryManagementService CategoryManagement
@inject DataDictionaryService DictionaryService
@inject TemplateRenderingService TemplateRendering
@inject Microsoft.Extensions.Options.IOptionsMonitor<AiOpenAiOptions> AiOptionsMonitor
@using System.Text.Json
@using TelegramPanel.Data.Entities
@using TelegramPanel.Modules
@@ -8,6 +11,7 @@
<MudStack Spacing="2">
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Dense="true">
持续活跃任务会在后台循环执行并按间隔发送内容。MaxMessages=0 表示持续运行,直到你在任务中心手动取消。
词典消息现在支持 <code>{time}</code> 和文本字典变量;任务仍会先按“随机 / 队列”模式选中一行,再解析其中变量后发送。
</MudAlert>
@if (loading)
@@ -36,14 +40,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" />
<MudTextField @bind-Value="dictionaryText" @bind-Value:after="OnInputsChanged"
Label="词典(每行一个)"
Variant="Variant.Outlined"
Lines="6"
Immediate="true"
HelperText="任务会按“随机/队列”模式从词典中取一条消息发送。" />
HelperText="支持 {time} 和文本字典变量。任务会先按“随机 / 队列”模式选中一行,再解析变量后发送。" />
@if (textDictionaryNames.Count > 0)
{
<MudText Typo="Typo.caption">可用文本变量:<code>{time}</code>、@(string.Join("、", textDictionaryNames.Select(x => $"{{{x}}}")))</MudText>
}
else
{
<MudText Typo="Typo.caption">当前可用内置变量:<code>{time}</code></MudText>
}
<MudGrid>
<MudItem xs="12" md="4">
@@ -84,6 +97,46 @@
</MudSelect>
</MudItem>
</MudGrid>
<MudDivider Class="my-2" />
<MudSwitch T="bool" @bind-Value="enableAiVerification" @bind-Value:after="OnInputsChanged"
Label="启用 AI 验证消息识别" Color="Color.Primary" />
@if (enableAiVerification)
{
<MudGrid>
<MudItem xs="12" md="6">
<MudSelect T="string" @bind-Value="selectedAiModelOption" @bind-Value:after="OnInputsChanged"
Label="任务模型" Variant="Variant.Outlined" Dense="true"
HelperText="可使用全局默认模型,也可以为当前任务单独指定模型。">
<MudSelectItem Value="@UseGlobalDefaultModelOption">@UseGlobalDefaultModelLabel</MudSelectItem>
@foreach (var model in selectableAiModels)
{
<MudSelectItem Value="@model">@model</MudSelectItem>
}
<MudSelectItem Value="@CustomModelOption">自定义模型</MudSelectItem>
</MudSelect>
</MudItem>
<MudItem xs="12" md="6">
<MudNumericField T="int" @bind-Value="verificationTimeoutSeconds" @bind-Value:after="OnInputsChanged"
Label="验证等待超时(秒)" Variant="Variant.Outlined" Min="3" Max="300"
HelperText="发送活跃消息后,在此时间窗口内等待验证机器人消息。" />
</MudItem>
</MudGrid>
@if (IsCustomAiModelSelected)
{
<MudTextField @bind-Value="customAiModel" @bind-Value:after="OnInputsChanged"
Label="自定义模型名" Variant="Variant.Outlined"
HelperText="例如:gpt-4o-mini、qwen-vl-plus、glm-4.5v。" />
}
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Dense="true">
仅处理同一目标群里的 <strong>bot</strong> 验证消息,并要求它 <strong>&#64;当前执行账号</strong> 或 <strong>直接回复刚发出的消息</strong>。
有按钮时会优先让 AI 决定点击哪个按钮;没有按钮时会让 AI 直接生成回复文本。
</MudAlert>
}
}
</MudStack>
@@ -92,21 +145,35 @@
[Parameter] public EventCallback<ModuleTaskDraft> DraftChanged { get; set; }
[Parameter] public string? InitialConfigJson { get; set; }
private const string UseGlobalDefaultModelOption = "__use_global_default__";
private const string CustomModelOption = "__custom_model__";
private List<AccountCategory> categories = new();
private List<string> textDictionaryNames = new();
private List<string> selectableAiModels = new();
private bool loading = true;
private bool initializedFromConfig;
private IEnumerable<int> selectedCategoryIds = Array.Empty<int>();
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 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();
@@ -118,17 +185,21 @@
.OrderBy(x => x.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
if (!selectedCategoryIds.Any())
{
var firstCategoryId = categories.FirstOrDefault()?.Id ?? 0;
selectedCategoryIds = firstCategoryId > 0 ? new[] { firstCategoryId } : Array.Empty<int>();
}
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();
LoadAiSettings();
}
finally
{
loading = false;
await PushDraftAsync();
}
await PushDraftAsync();
}
private void ApplyInitialConfigIfNeeded()
@@ -148,10 +219,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<string>());
dictionaryText = string.Join(Environment.NewLine, cfg.Dictionary ?? new List<string>());
delayMinMs = ConvertMillisecondsToSeconds(cfg.DelayMinMs);
@@ -160,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
{
@@ -167,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<int> values)
{
selectedCategoryIds = NormalizeCategoryIds(values, 0);
@@ -215,6 +330,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, "间隔不能为负数"));
@@ -239,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
{
@@ -253,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;
@@ -1,4 +1,4 @@
@inject TelegramPanel.Web.Modules.ModuleContributionRegistry Contributions
@inject TelegramPanel.Web.Modules.ModuleContributionRegistry Contributions
<MudNavMenu>
<MudNavLink Href="/" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Dashboard">
@@ -33,6 +33,10 @@
任务中心
</MudNavLink>
<MudNavLink Href="/data-dictionaries" Icon="@Icons.Material.Filled.MenuBook">
数据字典
</MudNavLink>
<MudNavLink Href="/modules" Icon="@Icons.Material.Filled.Extension">
模块管理
</MudNavLink>
@@ -71,3 +75,5 @@
退出登录
</MudNavLink>
</MudNavMenu>
@@ -213,6 +213,7 @@
<MudMenuItem OnClick="OpenBatchChangeTwoFactorRecoveryEmail">批量换绑邮箱(找回+登录)(Cloud Mail)(已选)</MudMenuItem>
<MudMenuItem OnClick="OpenBatchSetAccountCategory">批量修改分类(已选)</MudMenuItem>
<MudMenuItem OnClick="OpenBatchChangeNickname">批量改昵称(已选)</MudMenuItem>
<MudMenuItem OnClick="OpenBatchChangeAvatar">批量改头像(已选)</MudMenuItem>
<MudMenuItem OnClick="OpenBatchChangeUsername">批量改用户名(已选)</MudMenuItem>
<MudMenuItem OnClick="OpenBatchChangeBio">批量改Bio(已选)</MudMenuItem>
</MudMenu>
@@ -682,6 +683,29 @@
await ReloadImportedAccountsAsync();
}
private async Task OpenBatchChangeAvatar()
{
if (Busy)
return;
var ids = selectedAccounts.Select(a => a.Id).Distinct().ToList();
if (ids.Count == 0)
{
Snackbar.Add("请先选择账号", Severity.Info);
return;
}
var parameters = new DialogParameters
{
["AccountIds"] = ids
};
var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Medium, FullWidth = true };
var dialog = DialogService.Show<TelegramPanel.Web.Components.Dialogs.BatchChangeAvatarDialog>("批量修改头像", parameters, options)!;
var result = await dialog.Result;
if (result is { Canceled: false })
await ReloadImportedAccountsAsync();
}
private async Task OpenBatchChangeUsername()
{
if (Busy)
@@ -84,6 +84,7 @@
<MudMenuItem Disabled="@(selectedAccounts.Count == 0)" OnClick="BatchKickAllOtherDevices">批量踢出所有其他设备(已选)</MudMenuItem>
<MudMenuItem Disabled="@(selectedAccounts.Count == 0)" OnClick="OpenBatchSetAccountCategory">批量修改分类(已选)</MudMenuItem>
<MudMenuItem Disabled="@(selectedAccounts.Count == 0)" OnClick="OpenBatchChangeNickname">批量改昵称(已选)</MudMenuItem>
<MudMenuItem Disabled="@(selectedAccounts.Count == 0)" OnClick="OpenBatchChangeAvatar">批量改头像(已选)</MudMenuItem>
<MudMenuItem Disabled="@(selectedAccounts.Count == 0)" OnClick="OpenBatchChangeUsername">批量改用户名(已选)</MudMenuItem>
<MudMenuItem Disabled="@(selectedAccounts.Count == 0)" OnClick="OpenBatchChangeBio">批量改Bio(已选)</MudMenuItem>
<MudMenuItem Disabled="@(selectedAccounts.Count == 0)" OnClick="DeleteSelectedAccounts">删除已选</MudMenuItem>
@@ -522,6 +523,29 @@
await ReloadTable();
}
private async Task OpenBatchChangeAvatar()
{
if (loading)
return;
var ids = selectedAccounts.Select(a => a.Id).Distinct().ToList();
if (ids.Count == 0)
{
Snackbar.Add("请先选择账号", Severity.Info);
return;
}
var parameters = new DialogParameters
{
["AccountIds"] = ids
};
var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Medium, FullWidth = true };
var dialog = DialogService.Show<TelegramPanel.Web.Components.Dialogs.BatchChangeAvatarDialog>("批量修改头像", parameters, options)!;
var result = await dialog.Result;
if (result is { Canceled: false })
await ReloadTable();
}
private async Task OpenBatchChangeUsername()
{
if (loading)
@@ -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);
@@ -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;
}
}
@@ -0,0 +1,215 @@
@page "/data-dictionaries"
@inject DataDictionaryService DictionaryService
@inject ISnackbar Snackbar
@inject IDialogService DialogService
@using TelegramPanel.Data.Entities
<PageTitle>数据字典 - Telegram Panel</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">数据字典管理</MudText>
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Dense="true" Class="mb-4">
当前系统内置变量:<code>{time}</code>,格式为 <code>yyyyMMddHHmmss</code>,例如 <code>20260310143059</code>。
它不是 Unix 时间戳,而是可直接阅读的时间字符串。
文本字段通常支持 <code>{time}</code> 和 <code>{字典名}</code>;图片字段通常只能使用单个图片字典变量,例如 <code>{avatar}</code>。
</MudAlert>
<MudCard>
<MudCardContent>
<MudStack Row="true" Spacing="2" AlignItems="AlignItems.Center" Wrap="Wrap.Wrap">
<MudTextField @bind-Value="searchText"
Label="搜索字典"
Variant="Variant.Outlined"
Immediate="true"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Search"
Style="min-width: 260px;" />
<MudSpacer />
<MudButton Variant="Variant.Outlined" Color="Color.Primary" StartIcon="@Icons.Material.Filled.TextFields" OnClick="@(() => OpenCreateDialog(DataDictionaryTypes.Text))">
新建文本字典
</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Secondary" StartIcon="@Icons.Material.Filled.Image" OnClick="@(() => OpenCreateDialog(DataDictionaryTypes.Image))">
新建图片字典
</MudButton>
<MudIconButton Icon="@Icons.Material.Filled.Refresh" Color="Color.Default" Disabled="@loading" OnClick="LoadDictionaries" />
</MudStack>
</MudCardContent>
</MudCard>
<MudCard Class="mt-4">
<MudCardContent>
<MudTable Items="@FilteredDictionaries" Hover="true" Breakpoint="Breakpoint.Sm" Loading="@loading">
<HeaderContent>
<MudTh>显示名称</MudTh>
<MudTh>变量名</MudTh>
<MudTh>类型</MudTh>
<MudTh>读取模式</MudTh>
<MudTh>状态</MudTh>
<MudTh>内容数</MudTh>
<MudTh>轮询游标</MudTh>
<MudTh>更新时间</MudTh>
<MudTh>操作</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="显示名称">@context.DisplayName</MudTd>
<MudTd DataLabel="变量名"><code>@($"{{{context.Name}}}")</code></MudTd>
<MudTd DataLabel="类型">
<MudChip T="string" Size="Size.Small" Color="@(IsImageDictionary(context) ? Color.Secondary : Color.Primary)">
@(IsImageDictionary(context) ? "图片" : "文本")
</MudChip>
</MudTd>
<MudTd DataLabel="读取模式">@(IsQueueMode(context) ? "队列轮询" : "随机")</MudTd>
<MudTd DataLabel="状态">
<MudChip T="string" Size="Size.Small" Color="@(context.IsEnabled ? Color.Success : Color.Warning)">
@(context.IsEnabled ? "启用" : "停用")
</MudChip>
</MudTd>
<MudTd DataLabel="内容数">@context.Items.Count(x => x.IsEnabled)</MudTd>
<MudTd DataLabel="轮询游标">@context.NextIndex</MudTd>
<MudTd DataLabel="更新时间"><PanelTime Value="context.UpdatedAt" /></MudTd>
<MudTd DataLabel="操作">
<MudStack Row="true" Spacing="1">
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small" Color="Color.Primary" OnClick="@(() => OpenEditDialog(context))" />
<MudIconButton Icon="@(context.IsEnabled ? Icons.Material.Filled.ToggleOn : Icons.Material.Filled.ToggleOff)"
Size="Size.Small"
Color="@(context.IsEnabled ? Color.Success : Color.Warning)"
OnClick="@(() => ToggleEnabled(context))" />
<MudIconButton Icon="@Icons.Material.Filled.RestartAlt" Size="Size.Small" Color="Color.Info" OnClick="@(() => ResetQueue(context))" />
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error" OnClick="@(() => DeleteDictionary(context))" />
</MudStack>
</MudTd>
</RowTemplate>
<NoRecordsContent>
<MudText>暂无数据字典</MudText>
</NoRecordsContent>
</MudTable>
</MudCardContent>
</MudCard>
@code {
private List<DataDictionary> dictionaries = new();
private bool loading = true;
private string searchText = string.Empty;
private IEnumerable<DataDictionary> 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<TelegramPanel.Web.Components.Dialogs.DataDictionaryEditDialog>(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<TelegramPanel.Web.Components.Dialogs.DataDictionaryEditDialog>($"编辑字典:{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);
}
@@ -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;
}
}
@@ -10,10 +10,11 @@
@inject BatchTaskManagementService TaskManagement
@inject ISnackbar Snackbar
@inject IDialogService DialogService
@inject IWebHostEnvironment Environment
@inject IWebHostEnvironment HostEnvironment
@inject PanelTimeZoneService TimeZone
@inject NavigationManager Navigation
@inject CloudMailClient CloudMail
@inject TelegramPanelAiService PanelAiService
@using TelegramPanel.Web.Services
@using TelegramPanel.Core.Utils
@@ -81,6 +82,49 @@
</MudButton>
</MudCardActions>
</MudCard>
<MudCard Class="mb-4">
<MudCardHeader>
<MudText Typo="Typo.h6">AI 设置</MudText>
</MudCardHeader>
<MudCardContent>
<MudTextField @bind-Value="aiEndpoint" Label="OpenAI 自定义端点" Variant="Variant.Outlined"
HelperText="只填写 https://xxx.com/v1,程序会自动拼接 /chat/completions" />
<MudTextField @bind-Value="aiApiKey" Label="API Key" Variant="Variant.Outlined" Class="mt-3"
InputType="InputType.Password"
HelperText="后续任务和模块会复用这套全局 AI 配置。" />
<MudTextField @bind-Value="aiDefaultModel" Label="默认模型" Variant="Variant.Outlined" Class="mt-3"
HelperText="任务里未单独选择模型时,回退使用这里的默认模型。" />
<MudTextField @bind-Value="aiPresetModelsText" Label="预设模型(每行一个)" Variant="Variant.Outlined" Class="mt-3"
Lines="4"
HelperText="例如:gpt-4o-mini、qwen-vl-plus;任务编辑器会直接复用这里的预设。" />
<MudNumericField @bind-Value="aiRetryCount" Label="AI 失败重试次数" Variant="Variant.Outlined" Class="mt-3"
Min="0" Max="5"
HelperText="0 表示不重试;实际最多请求次数 = 1 + 重试次数。建议 2。" />
<MudText Typo="Typo.caption" Class="mt-2 mud-text-secondary">
测试连通会直接使用当前表单里的端点、Key、模型和重试次数,不必先保存。
</MudText>
</MudCardContent>
<MudCardActions>
<MudButton Variant="Variant.Filled" Color="Color.Primary"
OnClick="@(async (MouseEventArgs _) => await SaveAiConfig())">
保存 AI 配置
</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Info"
Disabled="@aiTesting"
OnClick="@(async (MouseEventArgs _) => await TestAiConnectionAsync())">
@if (aiTesting)
{
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="mr-2" />
<span>测试中...</span>
}
else
{
<span>测试连通</span>
}
</MudButton>
</MudCardActions>
</MudCard>
<MudCard>
<MudCardHeader>
@@ -286,6 +330,12 @@
private string cloudMailAdminEmail = "";
private string cloudMailAdminPassword = "";
private bool cloudMailGeneratingToken = false;
private string aiEndpoint = "";
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);
@@ -308,7 +358,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 +383,11 @@
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)));
aiRetryCount = AiOpenAiSettingsSnapshot.NormalizeRetryCount(Configuration.GetValue("AI:OpenAI:RetryCount", 2));
}
private async Task SaveApiConfig()
@@ -432,6 +487,112 @@
}
}
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");
var openAi = EnsureObject(ai, "OpenAI");
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)
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);
aiRetryCount = normalizedRetryCount;
Snackbar.Add("AI 配置已保存(配置热重载后生效)", Severity.Success);
StateHasChanged();
}
catch (Exception ex)
{
Snackbar.Add($"保存失败:{ex.Message}(路径:{LocalConfigPath})", Severity.Error);
}
}
private async Task TestAiConnectionAsync()
{
if (aiTesting)
return;
if (aiRetryCount < 0 || aiRetryCount > 5)
{
Snackbar.Add("AI 失败重试次数范围应为 0-5", Severity.Error);
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 normalizedRetryCount = AiOpenAiSettingsSnapshot.NormalizeRetryCount(aiRetryCount);
var snapshot = new AiOpenAiSettingsSnapshot(normalizedEndpoint, normalizedApiKey, normalizedDefaultModel, presetModels, normalizedRetryCount);
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)
@@ -536,7 +697,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);
@@ -552,6 +713,18 @@
return created;
}
private static List<string> ParseLines(string? text)
{
if (string.IsNullOrWhiteSpace(text))
return new List<string>();
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)
@@ -1,5 +1,7 @@
@page "/tasks"
@inject BatchTaskManagementService TaskManagement
@inject ScheduledTaskService ScheduledTaskManagement
@inject ImageAssetStorageService AssetStorage
@inject TelegramPanel.Web.Modules.ModuleContributionRegistry Contributions
@inject IEnumerable<IModuleTaskRerunBuilder> TaskRerunBuilders
@inject PanelTimeZoneService TimeZone
@@ -48,6 +50,83 @@
</MudCardContent>
</MudCard>
<MudCard Class="mb-4">
<MudCardContent>
<MudTable Items="@scheduledTasks" Hover="true" Breakpoint="Breakpoint.Sm" Loading="@loading">
<ToolBarContent>
<MudText Typo="Typo.h6">计划任务 (@scheduledTasks.Count)</MudText>
</ToolBarContent>
<HeaderContent>
<MudTh>ID</MudTh>
<MudTh>归属</MudTh>
<MudTh>任务类型</MudTh>
<MudTh>状态</MudTh>
<MudTh>Cron</MudTh>
<MudTh>下次运行</MudTh>
<MudTh>上次运行</MudTh>
<MudTh>操作</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="ID">@context.Id</MudTd>
<MudTd DataLabel="归属">
<MudChip T="string" Size="Size.Small" Color="@GetCategoryColor(context.TaskType)">@GetCategoryName(context.TaskType)</MudChip>
</MudTd>
<MudTd DataLabel="任务类型">
<MudChip T="string" Size="Size.Small" Icon="@GetTaskIcon(context.TaskType)">@GetTaskTypeName(context.TaskType)</MudChip>
</MudTd>
<MudTd DataLabel="状态">
<MudChip T="string" Size="Size.Small" Color="@GetScheduledStatusColor(context.Status)">@GetScheduledStatusName(context.Status)</MudChip>
</MudTd>
<MudTd DataLabel="Cron">
<MudText Typo="Typo.body2">@context.CronExpression</MudText>
<MudText Typo="Typo.caption">时区:@TimeZone.Current.Id</MudText>
</MudTd>
<MudTd DataLabel="下次运行">
@if (context.NextRunAtUtc.HasValue)
{
<PanelTime Value="context.NextRunAtUtc.Value" />
}
else
{
<span>-</span>
}
</MudTd>
<MudTd DataLabel="上次运行">
@if (context.LastRunAtUtc.HasValue)
{
<PanelTime Value="context.LastRunAtUtc.Value" />
}
else
{
<span>-</span>
}
</MudTd>
<MudTd DataLabel="操作">
<MudIconButton Icon="@Icons.Material.Filled.Info" Size="Size.Small" Color="Color.Info"
OnClick="@(() => ShowScheduledTaskDetails(context))" />
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small" Color="Color.Primary"
OnClick="@(() => OpenEditScheduledTask(context))" />
@if (string.Equals(context.Status, ScheduledTaskStatuses.Enabled, StringComparison.OrdinalIgnoreCase))
{
<MudIconButton Icon="@Icons.Material.Filled.Pause" Size="Size.Small" Color="Color.Warning"
OnClick="@(() => PauseScheduledTask(context))" />
}
else
{
<MudIconButton Icon="@Icons.Material.Filled.PlayArrow" Size="Size.Small" Color="Color.Success"
OnClick="@(() => ResumeScheduledTask(context))" />
}
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error"
OnClick="@(() => DeleteScheduledTask(context))" />
</MudTd>
</RowTemplate>
<NoRecordsContent>
<MudText>暂无计划任务</MudText>
</NoRecordsContent>
</MudTable>
</MudCardContent>
</MudCard>
<MudCard Class="mb-4">
<MudCardContent>
<MudTable Items="@activeTasks" Hover="true" Breakpoint="Breakpoint.Sm" Loading="@loading">
@@ -192,6 +271,7 @@
@code {
private List<BatchTask> tasks = new();
private List<ScheduledTask> 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)
{
@@ -388,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";
@@ -407,6 +486,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");
@@ -469,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)
@@ -811,6 +900,7 @@
{
try
{
await CleanupBatchTaskAssetsAsync(task);
await TaskManagement.DeleteTaskAsync(task.Id);
await LoadTasks();
Snackbar.Add(isActive ? "任务已删除,后台执行将在下一次状态检查时停止" : "任务已删除", Severity.Success);
@@ -861,7 +951,10 @@
}
foreach (var item in historyTaskList)
{
await CleanupBatchTaskAssetsAsync(item);
await TaskManagement.DeleteTaskAsync(item.Id);
}
Snackbar.Add($"已清理 {historyTaskList.Count} 条历史任务记录", Severity.Success);
}
@@ -871,7 +964,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 +981,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", "<br/>", 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<TelegramPanel.Web.Components.Dialogs.EditScheduledTaskDialog>($"编辑计划任务 #{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 +1243,4 @@
@@ -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",
+13
View File
@@ -409,21 +409,34 @@ builder.Services.AddScoped<BotChannelAdminDefaultsService>();
builder.Services.AddScoped<ChannelAdminDefaultsService>();
builder.Services.AddScoped<ChannelAdminPresetsService>();
builder.Services.AddScoped<ChannelInvitePresetsService>();
builder.Services.AddSingleton<ImageAssetStorageService>();
builder.Services.AddSingleton<CronExpressionService>();
builder.Services.AddScoped<DataDictionaryService>();
builder.Services.AddScoped<TemplateRenderingService>();
builder.Services.AddScoped<ScheduledTaskService>();
builder.Services.Configure<UpdateCheckOptions>(builder.Configuration.GetSection("UpdateCheck"));
builder.Services.Configure<SelfUpdateOptions>(builder.Configuration.GetSection("SelfUpdate"));
builder.Services.Configure<AiOpenAiOptions>(builder.Configuration.GetSection("AI:OpenAI"));
builder.Services.AddSingleton<UpdateCheckService>();
builder.Services.AddSingleton<AppSelfUpdateService>();
builder.Services.Configure<PanelTimeZoneOptions>(builder.Configuration.GetSection("System"));
builder.Services.AddSingleton<PanelTimeZoneService>();
builder.Services.AddScoped<UserChatActiveAiVerificationService>();
builder.Services.AddSingleton<TelegramPanelAiService>();
builder.Services.AddSingleton<TelegramPanel.Modules.ITelegramPanelAiService>(sp => sp.GetRequiredService<TelegramPanelAiService>());
builder.Services.AddScoped<IModuleTaskHandler, BotChannelSetAdminsByAccountTaskHandler>();
builder.Services.AddScoped<IModuleTaskHandler, BotSetAdminsTaskHandler>();
builder.Services.AddScoped<IModuleTaskHandler, UserChatActiveTaskHandler>();
builder.Services.AddScoped<IModuleTaskHandler, ChannelGroupPrivateCreateTaskHandler>();
builder.Services.AddScoped<IModuleTaskHandler, ChannelGroupPublicizeTaskHandler>();
builder.Services.AddHostedService<BatchTaskBackgroundService>();
builder.Services.AddHostedService<ScheduledTaskBackgroundService>();
builder.Services.AddHostedService<AccountDataAutoSyncBackgroundService>();
builder.Services.AddHostedService<BotAutoSyncBackgroundService>();
builder.Services.AddHostedService<TelegramPanel.Web.Services.WebhookRegistrationService>();
builder.Services.AddHttpClient<TelegramBotApiClient>();
builder.Services.AddHttpClient<TelegramPanel.Web.Services.CloudMailClient>();
builder.Services.AddHttpClient(nameof(TelegramPanelAiService));
builder.Services.AddScoped<TelegramPanel.Modules.ITelegramEmailCodeService, TelegramPanel.Web.Services.TelegramEmailCodeService>();
builder.Services.AddModuleSystem(builder.Configuration, builder.Environment);
builder.Services.AddSingleton<AppRestartService>();
@@ -16,6 +16,7 @@ public sealed class AccountDataAutoSyncBackgroundService : BackgroundService
private readonly IConfiguration _configuration;
private readonly IWebHostEnvironment _environment;
private readonly ILogger<AccountDataAutoSyncBackgroundService> _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<string>();
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
@@ -144,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)
@@ -0,0 +1,199 @@
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<string> PresetModels { get; set; } = new();
public int RetryCount { get; set; } = 2;
public AiOpenAiSettingsSnapshot ToSnapshot()
{
return AiOpenAiSettingsSnapshot.From(this);
}
}
public sealed record AiOpenAiSettingsSnapshot(
string? Endpoint,
string? ApiKey,
string? DefaultModel,
IReadOnlyList<string> PresetModels,
int RetryCount)
{
public static AiOpenAiSettingsSnapshot Empty { get; } = new(null, null, null, Array.Empty<string>(), 2);
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<string> GetSelectableModels()
{
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var list = new List<string>();
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,
NormalizeRetryCount(options.RetryCount));
}
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 int NormalizeRetryCount(int value)
{
if (value < 0)
return 0;
return value > 5 ? 5 : value;
}
public static List<string> NormalizeModelEntries(IEnumerable<string>? values, string? defaultModel = null)
{
var normalizedDefault = NormalizeModel(defaultModel);
var result = new List<string>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var item in values ?? Array.Empty<string>())
{
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;
}
}
@@ -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<int> CategoryIds { get; set; } = new();
[JsonPropertyName("category_names")]
public List<string> 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<int> CategoryIds { get; set; } = new();
[JsonPropertyName("category_names")]
public List<string> 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; }
}
@@ -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;
/// <summary>
/// 自动创建私密频道/群组任务处理器。
/// </summary>
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<AccountManagementService>();
var channelManagement = host.Services.GetRequiredService<ChannelManagementService>();
var groupManagement = host.Services.GetRequiredService<GroupManagementService>();
var channelService = host.Services.GetRequiredService<IChannelService>();
var groupService = host.Services.GetRequiredService<IGroupService>();
var taskManagement = host.Services.GetRequiredService<BatchTaskManagementService>();
var templateRendering = host.Services.GetRequiredService<TemplateRenderingService>();
var assetStorage = host.Services.GetRequiredService<ImageAssetStorageService>();
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<ChannelGroupPrivateCreateTaskConfig>(config)
?? throw new InvalidOperationException("任务配置解析失败");
}
private static void Validate(ChannelGroupPrivateCreateTaskConfig config)
{
config.CategoryIds = (config.CategoryIds ?? new List<int>())
.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;
}
}
@@ -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;
/// <summary>
/// 将私密频道/群组公开化任务处理器。
/// </summary>
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<AccountManagementService>();
var channelManagement = host.Services.GetRequiredService<ChannelManagementService>();
var groupManagement = host.Services.GetRequiredService<GroupManagementService>();
var channelService = host.Services.GetRequiredService<IChannelService>();
var groupService = host.Services.GetRequiredService<IGroupService>();
var taskManagement = host.Services.GetRequiredService<BatchTaskManagementService>();
var templateRendering = host.Services.GetRequiredService<TemplateRenderingService>();
var assetStorage = host.Services.GetRequiredService<ImageAssetStorageService>();
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<ChannelGroupPublicizeTaskConfig>(config)
?? throw new InvalidOperationException("任务配置解析失败");
}
private static void Validate(ChannelGroupPublicizeTaskConfig config)
{
config.CategoryIds = (config.CategoryIds ?? new List<int>())
.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 位小写字母/数字/下划线,且以字母开头");
}
}
@@ -0,0 +1,68 @@
using TelegramPanel.Modules;
namespace TelegramPanel.Web.Services;
/// <summary>
/// 频道/群组任务延时辅助工具。
/// </summary>
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<bool> 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;
}
}
@@ -0,0 +1,269 @@
namespace TelegramPanel.Web.Services;
/// <summary>
/// 轻量 Cron 表达式服务(5 位:分 时 日 月 周)。
/// </summary>
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;
}
}
}
}
@@ -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);
/// <summary>
/// 数据字典业务服务。
/// </summary>
public sealed class DataDictionaryService
{
private static readonly ConcurrentDictionary<int, SemaphoreSlim> 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<IReadOnlyList<DataDictionary>> GetAllAsync(CancellationToken cancellationToken = default)
{
return await _dictionaryRepository.GetAllWithItemsAsync(cancellationToken);
}
public async Task<DataDictionary?> GetAsync(int id, CancellationToken cancellationToken = default)
{
return await _dictionaryRepository.GetWithItemsAsync(id, cancellationToken);
}
public async Task<DataDictionary?> GetByNameAsync(string name, CancellationToken cancellationToken = default)
{
return await _dictionaryRepository.GetByNameAsync(NormalizeName(name), cancellationToken);
}
public async Task<DataDictionary> SaveTextDictionaryAsync(
int? id,
string name,
string displayName,
string? description,
string readMode,
bool isEnabled,
IReadOnlyList<DataDictionaryTextItemInput> 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<DataDictionaryTextItemInput>())
.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<DataDictionary> SaveImageDictionaryAsync(
int? id,
string name,
string displayName,
string? description,
string readMode,
bool isEnabled,
IReadOnlyCollection<int> keepItemIds,
IReadOnlyList<DataDictionaryImageItemInput> 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<int>()).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<DataDictionaryImageItemInput>())
{
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<string> 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<StoredImageAssetInfo> 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<DataDictionary> 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;
}
}
@@ -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('\\', '/');
}
/// <summary>
/// 图片资产存储服务。
/// </summary>
public sealed class ImageAssetStorageService
{
private readonly IWebHostEnvironment _environment;
private readonly ILogger<ImageAssetStorageService> _logger;
public ImageAssetStorageService(IWebHostEnvironment environment, ILogger<ImageAssetStorageService> logger)
{
_environment = environment;
_logger = logger;
}
public async Task<StoredImageAssetInfo> 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<StoredImageAssetInfo> 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<Stream> 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);
}
}
@@ -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();
@@ -0,0 +1,95 @@
using TelegramPanel.Core.Services;
using TelegramPanel.Data.Entities;
namespace TelegramPanel.Web.Services;
/// <summary>
/// Cron 计划任务后台调度器。
/// </summary>
public sealed class ScheduledTaskBackgroundService : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<ScheduledTaskBackgroundService> _logger;
public ScheduledTaskBackgroundService(
IServiceScopeFactory scopeFactory,
ILogger<ScheduledTaskBackgroundService> 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<ScheduledTaskService>();
var batchTaskManagement = scope.ServiceProvider.GetRequiredService<BatchTaskManagementService>();
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);
}
}
}
@@ -0,0 +1,143 @@
using TelegramPanel.Core.Services;
using TelegramPanel.Data.Entities;
using TelegramPanel.Data.Repositories;
namespace TelegramPanel.Web.Services;
/// <summary>
/// 计划任务业务服务。
/// </summary>
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<IReadOnlyList<ScheduledTask>> GetAllAsync(CancellationToken cancellationToken = default)
{
return await _scheduledTaskRepository.GetAllOrderedAsync(cancellationToken);
}
public async Task<ScheduledTask?> GetAsync(int id, CancellationToken cancellationToken = default)
{
return await _scheduledTaskRepository.GetByIdAsync(id);
}
public async Task<ScheduledTask> 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<ScheduledTask> 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<IReadOnlyList<ScheduledTask>> 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;
}
}
@@ -0,0 +1,79 @@
using System.Text.Json.Nodes;
namespace TelegramPanel.Web.Services;
/// <summary>
/// 任务配置中的图片资产作用域辅助工具。
/// </summary>
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<string>();
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 LocalConfigFile.ToIndentedJson(node);
}
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 LocalConfigFile.ToIndentedJson(node);
}
catch
{
return configJson;
}
}
private static string? Normalize(string? value)
{
var text = (value ?? string.Empty).Trim();
return text.Length == 0 ? null : text;
}
}
@@ -0,0 +1,432 @@
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<AiOpenAiOptions> _optionsMonitor;
private readonly ILogger<TelegramPanelAiService> _logger;
public TelegramPanelAiService(
IHttpClientFactory httpClientFactory,
IOptionsMonitor<AiOpenAiOptions> optionsMonitor,
ILogger<TelegramPanelAiService> logger)
{
_httpClientFactory = httpClientFactory;
_optionsMonitor = optionsMonitor;
_logger = logger;
}
public async Task<TelegramPanelAiChooseActionResult> 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<TelegramPanelAiButtonOption>();
var buttonLines = buttons.Select(x => $"{x.Index}. {x.Text}").ToArray();
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 验证消息。");
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
{
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 after retries");
return new TelegramPanelAiChooseActionResult(false, null, null, null, null, $"AI 响应解析失败:{ex.Message}");
}
}
public async Task<TelegramPanelAiReplyTextResult> 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
{
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);
},
cancellationToken: cancellationToken);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to generate AI reply text after retries");
return new TelegramPanelAiReplyTextResult(false, null, $"AI 请求失败:{ex.Message}");
}
}
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 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)
return (false, model, null, "AI 返回了空响应");
return (true, model, TrimForPreview(normalized), null);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "AI connectivity test failed after retries");
return (false, model, null, ex.Message);
}
}
private async Task<TelegramPanelAiChooseActionResult> 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<T> ExecuteWithRetryAsync<T>(
string operationName,
string model,
int retryCount,
Func<CancellationToken, Task<T>> 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<string> 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 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();
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];
}
private static string TrimForPreview(string text)
{
var normalized = (text ?? string.Empty).Replace("\r", " ").Replace("\n", " ").Trim();
return normalized.Length <= 80 ? normalized : normalized[..80];
}
}
@@ -0,0 +1,143 @@
using System.Text;
using System.Text.RegularExpressions;
using TelegramPanel.Data.Entities;
namespace TelegramPanel.Web.Services;
/// <summary>
/// 模板变量解析服务。
/// </summary>
public sealed class TemplateRenderingService
{
private static readonly Regex TokenRegex = new("\\{(?<name>[a-zA-Z0-9_]+)\\}", RegexOptions.Compiled);
private readonly DataDictionaryService _dataDictionaryService;
public TemplateRenderingService(DataDictionaryService dataDictionaryService)
{
_dataDictionaryService = dataDictionaryService;
}
public IReadOnlyList<string> ExtractTokenNames(string? template)
{
if (string.IsNullOrWhiteSpace(template))
return Array.Empty<string>();
var names = new List<string>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (Match match in TokenRegex.Matches(template))
{
if (!match.Success)
continue;
var tokenName = (match.Groups["name"].Value ?? string.Empty).Trim();
if (tokenName.Length == 0 || !seen.Add(tokenName))
continue;
names.Add(tokenName);
}
return names;
}
public async Task ValidateTextTemplateAsync(string? template, CancellationToken cancellationToken = default)
{
foreach (var tokenName in ExtractTokenNames(template))
{
if (string.Equals(tokenName, "time", StringComparison.OrdinalIgnoreCase))
continue;
await EnsureDictionaryAvailableAsync(tokenName, DataDictionaryTypes.Text, cancellationToken);
}
}
public async Task ValidateImageTemplateAsync(string? tokenExpression, CancellationToken cancellationToken = default)
{
var tokenName = ExtractSingleTokenName(tokenExpression)
?? throw new InvalidOperationException("图片变量必须是单个字典变量,例如 {avatar}");
await EnsureDictionaryAvailableAsync(tokenName, DataDictionaryTypes.Image, cancellationToken);
}
public async Task<string> RenderTextTemplateAsync(string template, CancellationToken cancellationToken = default)
{
template = template ?? string.Empty;
var matches = TokenRegex.Matches(template);
if (matches.Count == 0)
return template;
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;
var resolved = await ResolveTextTokenAsync(tokenName, cancellationToken);
builder.Append(resolved);
lastIndex = match.Index + match.Length;
}
builder.Append(template, lastIndex, template.Length - lastIndex);
return builder.ToString();
}
public async Task<StoredImageAssetInfo> 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<string> ResolveTextTokenAsync(string tokenName, CancellationToken cancellationToken)
{
if (string.Equals(tokenName, "time", StringComparison.OrdinalIgnoreCase))
return DateTime.Now.ToString("yyyyMMddHHmmss");
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}}}");
}
}
}
@@ -0,0 +1,179 @@
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<UserChatActiveAiVerificationService> _logger;
public UserChatActiveAiVerificationService(
AccountTelegramToolsService accountTools,
ITelegramPanelAiService aiService,
ILogger<UserChatActiveAiVerificationService> 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 mappedButton = TryMatchButtonByReplyText(candidate.Buttons, replyText);
if (mappedButton != null)
{
var mappedClick = await _accountTools.ClickInlineButtonAsync(
account.Id,
target,
candidate.MessageId,
mappedButton.CallbackData,
cancellationToken);
if (!mappedClick.Success)
return (false, mappedClick.Error, null);
return (true, null, $"AI 文本映射按钮:{mappedButton.Text}");
}
return (false, $"验证码消息存在按钮,但 AI 返回了文本:{replyText}", null);
}
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}");
}
private static TelegramInlineButtonOption? TryMatchButtonByReplyText(
IReadOnlyList<TelegramInlineButtonOption> 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);
}
}
@@ -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;
@@ -21,9 +22,21 @@ public sealed class UserChatActiveTaskHandler : IModuleTaskHandler
var taskManagement = host.Services.GetRequiredService<BatchTaskManagementService>();
var accountManagement = host.Services.GetRequiredService<AccountManagementService>();
var accountTools = host.Services.GetRequiredService<AccountTelegramToolsService>();
var templateRendering = host.Services.GetRequiredService<TemplateRenderingService>();
var aiVerification = host.Services.GetRequiredService<UserChatActiveAiVerificationService>();
var aiOptions = host.Services.GetRequiredService<IOptionsMonitor<AiOpenAiOptions>>();
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())
@@ -114,13 +127,78 @@ 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))
{
config.Canceled = true;
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,
text,
cancellationToken);
cancellationToken: cancellationToken);
completed++;
var hadFailureThisRound = false;
@@ -144,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))
{
@@ -155,8 +269,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)
@@ -229,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);
@@ -298,6 +419,29 @@ public sealed class UserChatActiveTaskHandler : IModuleTaskHandler
return Random.Shared.Next(minMs, maxMs + 1);
}
private static async Task<bool> 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)
@@ -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; }
@@ -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);