merge: dev into main for v1.31.7
This commit is contained in:
commit
cac15092fa
79 files changed
+8408
-391
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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!;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Generated
+917
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+158
@@ -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>@当前执行账号</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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user