diff --git a/Directory.Build.props b/Directory.Build.props
index c27c5d0..67acfc4 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -5,10 +5,10 @@
- 1.31.6
- 1.31.6.0
- 1.31.6.0
- 1.31.6
+ 1.31.7
+ 1.31.7.0
+ 1.31.7.0
+ 1.31.7
Telegram Panel Contributors
diff --git a/TelegramPanel.sln b/TelegramPanel.sln
index 301f599..228ca1e 100644
--- a/TelegramPanel.sln
+++ b/TelegramPanel.sln
@@ -1,4 +1,4 @@
-Microsoft Visual Studio Solution File, Format Version 12.00
+Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
diff --git a/docs/developer/modules.md b/docs/developer/modules.md
index 345be30..be65553 100644
--- a/docs/developer/modules.md
+++ b/docs/developer/modules.md
@@ -534,7 +534,56 @@ yield return new ModuleTaskDefinition
> 注意:这组字段已经进入抽象层,并且内置持续任务已按此方式声明;外部模块也建议遵循相同结构,便于后续宿主统一扩展任务中心行为。
-### 6) 为“重新运行”提供专用构建器(适合复杂任务)
+### 6) 宿主内置数据字典与模板变量(推荐优先复用)
+
+如果你的模块任务需要“随机文案 / 队列文案 / 图片变量 / 标题模板 / 用户名模板”等能力,建议优先复用宿主已经内置的数据字典体系,而不是在模块里重复造一套词库配置。
+
+当前宿主已经提供:
+
+- 数据字典管理页面:`/data-dictionaries`
+- 文本字典:返回 `string`
+- 图片字典:返回图片资产引用(适合头像、图片消息等)
+- 读取模式:`random` / `queue`
+- 队列游标持久化:`queue` 模式的 `NextIndex` 会写入数据库,重启后继续
+- 模板变量语法:固定为 `{name}`
+- 内置变量:`{time}`(格式 `yyyyMMddHHmmss`)
+
+相关宿主服务:
+
+- `TelegramPanel.Web.Services.DataDictionaryService`
+- `TelegramPanel.Web.Services.TemplateRenderingService`
+- `TelegramPanel.Web.Services.ImageAssetStorageService`
+
+推荐用法:
+
+```csharp
+var templateRendering = host.Services.GetRequiredService();
+
+var title = await templateRendering.RenderTextTemplateAsync("临时频道{time}_{city}", cancellationToken);
+var avatar = await templateRendering.ResolveImageTemplateAsync("{avatar_dict}", cancellationToken);
+```
+
+约束说明:
+
+- 标题、描述、公开用户名这类文本字段,只能解析到**文本值**
+- 头像、图片消息这类图片字段,只能使用**固定图片**或**图片字典变量**
+- 文本字典和图片字典**严格分型**,不要混用
+- 未知变量、空字典、已停用字典、类型不匹配,宿主会直接抛出校验失败
+- 图片变量必须是**单个 token**,例如 `{avatar}`,不能写成 `头像_{avatar}`
+
+如果你的模块也提供任务编辑器,建议:
+
+- 在 UI 中直接提示“支持 `{time}` 与 `{字典名}`”
+- 文本输入框只展示文本字典变量
+- 图片输入框只展示图片字典变量
+- 让最终配置 JSON 只保存模板字符串 / 字典 token,不要把解析后的随机结果提前固化进配置
+
+这样做的好处是:
+
+- 宿主统一管理字典内容,模块间可以复用同一份变量源
+- 后续扩展新变量 provider 时,模块通常不需要改协议
+- 计划任务、一次性任务、模块页面都能复用同一套解析规则
+### 7) 为“重新运行”提供专用构建器(适合复杂任务)
如果你的任务配置在运行过程中会写回运行态字段,或者重跑前需要清洗旧配置,建议额外实现 `IModuleTaskRerunBuilder`:
@@ -666,3 +715,5 @@ public IEnumerable GetApis(ModuleHostContext context)
+
+
diff --git a/src/TelegramPanel.Core/BatchTasks/BatchTaskTypes.cs b/src/TelegramPanel.Core/BatchTasks/BatchTaskTypes.cs
index ec2d94d..5c4b394 100644
--- a/src/TelegramPanel.Core/BatchTasks/BatchTaskTypes.cs
+++ b/src/TelegramPanel.Core/BatchTasks/BatchTaskTypes.cs
@@ -14,6 +14,8 @@ public static class BatchTaskTypes
// User 任务(新增)
public const string UserJoinSubscribe = "user_join_subscribe";
public const string UserChatActive = "user_chat_active";
+ public const string ChannelGroupPrivateCreate = "channel_group_private_create";
+ public const string ChannelGroupPublicize = "channel_group_publicize";
// System 任务(记录到任务中心)
public const string AccountAutoSync = "account_auto_sync";
diff --git a/src/TelegramPanel.Core/Interfaces/IChannelService.cs b/src/TelegramPanel.Core/Interfaces/IChannelService.cs
index 62a54d7..cceff80 100644
--- a/src/TelegramPanel.Core/Interfaces/IChannelService.cs
+++ b/src/TelegramPanel.Core/Interfaces/IChannelService.cs
@@ -82,6 +82,11 @@ public interface IChannelService
///
Task UpdateChannelInfoAsync(int accountId, long channelId, string title, string? about);
+ ///
+ /// 设置频道头像。
+ ///
+ Task SetChannelPhotoAsync(int accountId, long channelId, Stream fileStream, string fileName, CancellationToken cancellationToken = default);
+
///
/// 从频道踢出用户(通过 username),可选是否永久封禁
///
diff --git a/src/TelegramPanel.Core/Interfaces/IGroupService.cs b/src/TelegramPanel.Core/Interfaces/IGroupService.cs
index c0d939d..9e46171 100644
--- a/src/TelegramPanel.Core/Interfaces/IGroupService.cs
+++ b/src/TelegramPanel.Core/Interfaces/IGroupService.cs
@@ -46,4 +46,19 @@ public interface IGroupService
/// 获取群组管理员列表(需要权限)
///
Task> GetAdminsAsync(int accountId, long groupId);
+
+ ///
+ /// 更新群组标题与简介。
+ ///
+ Task UpdateGroupInfoAsync(int accountId, long groupId, string title, string? about);
+
+ ///
+ /// 设置群组公开用户名或切回私密。
+ ///
+ Task SetGroupVisibilityAsync(int accountId, long groupId, bool isPublic, string? username = null);
+
+ ///
+ /// 设置群组头像。
+ ///
+ Task SetGroupPhotoAsync(int accountId, long groupId, Stream fileStream, string fileName, CancellationToken cancellationToken = default);
}
diff --git a/src/TelegramPanel.Core/Models/TelegramAccountMessageUpdate.cs b/src/TelegramPanel.Core/Models/TelegramAccountMessageUpdate.cs
new file mode 100644
index 0000000..bb93708
--- /dev/null
+++ b/src/TelegramPanel.Core/Models/TelegramAccountMessageUpdate.cs
@@ -0,0 +1,27 @@
+using TL;
+
+namespace TelegramPanel.Core.Models;
+
+public sealed record TelegramInlineButton(
+ int Index,
+ int RowIndex,
+ int ColumnIndex,
+ string Text,
+ byte[] CallbackData);
+
+public sealed record TelegramAccountMessageUpdate(
+ int AccountId,
+ DateTimeOffset ReceivedAtUtc,
+ bool IsEdited,
+ Message Message,
+ long? SenderUserId,
+ string? SenderUsername,
+ bool SenderIsBot,
+ int? ReplyToMessageId,
+ int? ThreadId,
+ IReadOnlyList Buttons)
+{
+ public string Text => (Message.message ?? string.Empty).Trim();
+
+ public bool HasVisualMedia => Message.media is MessageMediaPhoto or MessageMediaDocument;
+}
diff --git a/src/TelegramPanel.Core/Models/TelegramVerificationModels.cs b/src/TelegramPanel.Core/Models/TelegramVerificationModels.cs
new file mode 100644
index 0000000..8bcb839
--- /dev/null
+++ b/src/TelegramPanel.Core/Models/TelegramVerificationModels.cs
@@ -0,0 +1,12 @@
+namespace TelegramPanel.Core.Models;
+
+public sealed record TelegramInlineButtonOption(int Index, string Text, byte[] CallbackData);
+
+public sealed record TelegramVerificationMessageCandidate(
+ int MessageId,
+ string? Text,
+ byte[]? ImageJpegBytes,
+ IReadOnlyList Buttons,
+ bool MentionsAccount,
+ bool IsReplyToSentMessage,
+ DateTime DateUtc);
diff --git a/src/TelegramPanel.Core/ServiceCollectionExtensions.cs b/src/TelegramPanel.Core/ServiceCollectionExtensions.cs
index cf9d40b..32650f2 100644
--- a/src/TelegramPanel.Core/ServiceCollectionExtensions.cs
+++ b/src/TelegramPanel.Core/ServiceCollectionExtensions.cs
@@ -12,6 +12,8 @@ public static class ServiceCollectionExtensions
{
public static IServiceCollection AddTelegramPanelCore(this IServiceCollection services)
{
+ services.AddSingleton();
+
// 注册 Telegram 客户端池(单例)
services.AddSingleton();
diff --git a/src/TelegramPanel.Core/Services/BatchTaskManagementService.cs b/src/TelegramPanel.Core/Services/BatchTaskManagementService.cs
index c1542a2..1c10619 100644
--- a/src/TelegramPanel.Core/Services/BatchTaskManagementService.cs
+++ b/src/TelegramPanel.Core/Services/BatchTaskManagementService.cs
@@ -26,7 +26,7 @@ public class BatchTaskManagementService
public async Task GetTaskAsync(int id)
{
- return await _batchTaskRepository.GetByIdAsync(id);
+ return await _batchTaskRepository.GetFreshByIdAsync(id);
}
public async Task> GetAllTasksAsync()
@@ -63,51 +63,51 @@ public class BatchTaskManagementService
public async Task UpdateTaskProgressAsync(int taskId, int completed, int failed)
{
- var task = await _batchTaskRepository.GetByIdAsync(taskId);
+ var task = await _batchTaskRepository.GetFreshByIdAsync(taskId);
if (task != null)
{
task.Completed = completed;
task.Failed = failed;
- await _batchTaskRepository.UpdateAsync(task);
+ await _batchTaskRepository.UpdateFreshAsync(task);
}
}
public async Task UpdateTaskConfigAsync(int taskId, string? config)
{
- var task = await _batchTaskRepository.GetByIdAsync(taskId);
+ var task = await _batchTaskRepository.GetFreshByIdAsync(taskId);
if (task != null)
{
task.Config = config;
- await _batchTaskRepository.UpdateAsync(task);
+ await _batchTaskRepository.UpdateFreshAsync(task);
}
}
public async Task UpdateTaskDraftAsync(int taskId, int total, string? config)
{
- var task = await _batchTaskRepository.GetByIdAsync(taskId);
+ var task = await _batchTaskRepository.GetFreshByIdAsync(taskId);
if (task != null)
{
if (total < 0) total = 0;
task.Total = total;
task.Config = config;
- await _batchTaskRepository.UpdateAsync(task);
+ await _batchTaskRepository.UpdateFreshAsync(task);
}
}
public async Task StartTaskAsync(int taskId)
{
- var task = await _batchTaskRepository.GetByIdAsync(taskId);
+ var task = await _batchTaskRepository.GetFreshByIdAsync(taskId);
if (task != null)
{
task.Status = "running";
task.StartedAt = DateTime.UtcNow;
- await _batchTaskRepository.UpdateAsync(task);
+ await _batchTaskRepository.UpdateFreshAsync(task);
}
}
public async Task PauseTaskAsync(int taskId)
{
- var task = await _batchTaskRepository.GetByIdAsync(taskId);
+ var task = await _batchTaskRepository.GetFreshByIdAsync(taskId);
if (task == null)
return;
@@ -115,13 +115,13 @@ public class BatchTaskManagementService
{
task.Status = "paused";
task.CompletedAt = null;
- await _batchTaskRepository.UpdateAsync(task);
+ await _batchTaskRepository.UpdateFreshAsync(task);
}
}
public async Task ResumeTaskAsync(int taskId)
{
- var task = await _batchTaskRepository.GetByIdAsync(taskId);
+ var task = await _batchTaskRepository.GetFreshByIdAsync(taskId);
if (task == null)
return;
@@ -130,25 +130,25 @@ public class BatchTaskManagementService
task.Status = "pending";
task.StartedAt = null;
task.CompletedAt = null;
- await _batchTaskRepository.UpdateAsync(task);
+ await _batchTaskRepository.UpdateFreshAsync(task);
}
}
public async Task CompleteTaskAsync(int taskId, bool success = true)
{
- var task = await _batchTaskRepository.GetByIdAsync(taskId);
+ var task = await _batchTaskRepository.GetFreshByIdAsync(taskId);
if (task == null)
return;
task.Status = success ? "completed" : "failed";
task.CompletedAt = DateTime.UtcNow;
- await _batchTaskRepository.UpdateAsync(task);
+ await _batchTaskRepository.UpdateFreshAsync(task);
await TrimHistoryTasksIfNeededAsync();
}
public async Task DeleteTaskAsync(int id)
{
- var task = await _batchTaskRepository.GetByIdAsync(id);
+ var task = await _batchTaskRepository.GetFreshByIdAsync(id);
if (task != null)
{
await _batchTaskRepository.DeleteAsync(task);
diff --git a/src/TelegramPanel.Core/Services/ChannelManagementService.cs b/src/TelegramPanel.Core/Services/ChannelManagementService.cs
index 0d9b149..608f7cb 100644
--- a/src/TelegramPanel.Core/Services/ChannelManagementService.cs
+++ b/src/TelegramPanel.Core/Services/ChannelManagementService.cs
@@ -1,4 +1,4 @@
-using TelegramPanel.Data.Entities;
+using TelegramPanel.Data.Entities;
using TelegramPanel.Data.Repositories;
namespace TelegramPanel.Core.Services;
@@ -84,6 +84,8 @@ public class ChannelManagementService
existing.CreatorAccountId = channel.CreatorAccountId;
if (channel.CreatedAt.HasValue)
existing.CreatedAt = channel.CreatedAt;
+ if (existing.SystemCreatedAtUtc == null && channel.SystemCreatedAtUtc != null)
+ existing.SystemCreatedAtUtc = channel.SystemCreatedAtUtc;
existing.SyncedAt = DateTime.UtcNow;
await _channelRepository.UpdateAsync(existing);
@@ -123,6 +125,8 @@ public class ChannelManagementService
legacy.GroupId = channel.GroupId;
if (channel.CreatedAt.HasValue)
legacy.CreatedAt = channel.CreatedAt;
+ if (legacy.SystemCreatedAtUtc == null && channel.SystemCreatedAtUtc != null)
+ legacy.SystemCreatedAtUtc = channel.SystemCreatedAtUtc;
legacy.SyncedAt = DateTime.UtcNow;
await _channelRepository.UpdateAsync(legacy);
@@ -280,3 +284,5 @@ public class ChannelManagementService
return await _accountChannelRepository.GetPreferredAdminAccountIdAsync(channel.Id);
}
}
+
+
diff --git a/src/TelegramPanel.Core/Services/GroupManagementService.cs b/src/TelegramPanel.Core/Services/GroupManagementService.cs
index 423fbda..a727f09 100644
--- a/src/TelegramPanel.Core/Services/GroupManagementService.cs
+++ b/src/TelegramPanel.Core/Services/GroupManagementService.cs
@@ -1,4 +1,4 @@
-using TelegramPanel.Data.Entities;
+using TelegramPanel.Data.Entities;
using TelegramPanel.Data.Repositories;
namespace TelegramPanel.Core.Services;
@@ -69,6 +69,8 @@ public class GroupManagementService
existing.CreatorAccountId = group.CreatorAccountId;
if (group.CreatedAt.HasValue)
existing.CreatedAt = group.CreatedAt;
+ if (existing.SystemCreatedAtUtc == null && group.SystemCreatedAtUtc != null)
+ existing.SystemCreatedAtUtc = group.SystemCreatedAtUtc;
existing.SyncedAt = DateTime.UtcNow;
await _groupRepository.UpdateAsync(existing);
@@ -223,3 +225,5 @@ public class GroupManagementService
return await _accountGroupRepository.GetPreferredAdminAccountIdAsync(group.Id);
}
}
+
+
diff --git a/src/TelegramPanel.Core/Services/Telegram/AccountTelegramToolsService.cs b/src/TelegramPanel.Core/Services/Telegram/AccountTelegramToolsService.cs
index 8d2e55e..c268be2 100644
--- a/src/TelegramPanel.Core/Services/Telegram/AccountTelegramToolsService.cs
+++ b/src/TelegramPanel.Core/Services/Telegram/AccountTelegramToolsService.cs
@@ -24,17 +24,20 @@ public class AccountTelegramToolsService
private readonly ITelegramClientPool _clientPool;
private readonly IConfiguration _configuration;
private readonly ILogger _logger;
+ private readonly TelegramAccountUpdateHub _updateHub;
public AccountTelegramToolsService(
AccountManagementService accountManagement,
ITelegramClientPool clientPool,
IConfiguration configuration,
- ILogger logger)
+ ILogger logger,
+ TelegramAccountUpdateHub updateHub)
{
_accountManagement = accountManagement;
_clientPool = clientPool;
_configuration = configuration;
_logger = logger;
+ _updateHub = updateHub;
}
///
@@ -1112,22 +1115,109 @@ public class AccountTelegramToolsService
///
/// 向已解析的群组/频道目标发送文本消息。
///
- public async Task<(bool Success, string? Error)> SendMessageToResolvedChatAsync(
+ public async Task<(bool Success, string? Error, int? MessageId)> SendMessageToResolvedChatAsync(
int accountId,
ResolvedChatTarget target,
string message,
+ int? replyToMessageId = null,
CancellationToken cancellationToken = default)
{
try
{
var text = (message ?? string.Empty).Trim();
if (text.Length == 0)
- return (false, "消息内容为空");
+ return (false, "消息内容为空", null);
var client = await GetOrCreateConnectedClientAsync(accountId, cancellationToken);
cancellationToken.ThrowIfCancellationRequested();
- _ = await client.SendMessageAsync(target.Peer, text);
+ var sent = await client.SendMessageAsync(target.Peer, text, null, replyToMessageId ?? 0);
+ return (true, null, sent.id);
+ }
+ catch (Exception ex)
+ {
+ var (summary, details) = MapTelegramException(ex);
+ var msg = string.IsNullOrWhiteSpace(details) ? summary : $"{summary}:{details}";
+ return (false, msg, null);
+ }
+ }
+
+ public async Task<(bool Success, string? Error, TelegramVerificationMessageCandidate? Candidate)> WaitForBotVerificationMessageAsync(
+ int accountId,
+ ResolvedChatTarget target,
+ int sentMessageId,
+ string? currentUsername,
+ int timeoutSeconds,
+ CancellationToken cancellationToken = default)
+ {
+ if (timeoutSeconds < 3)
+ timeoutSeconds = 3;
+ if (timeoutSeconds > 300)
+ timeoutSeconds = 300;
+
+ try
+ {
+ var client = await GetOrCreateConnectedClientAsync(accountId, cancellationToken);
+ var waitStartedAt = DateTimeOffset.UtcNow.AddSeconds(-2);
+ var update = await _updateHub.WaitForAsync(
+ accountId,
+ x => IsCandidateVerificationMessage(x, target, currentUsername, sentMessageId),
+ waitStartedAt,
+ TimeSpan.FromSeconds(timeoutSeconds),
+ cancellationToken);
+
+ if (update == null)
+ return (false, $"等待验证消息超时({timeoutSeconds} 秒)", null);
+
+ var candidate = await BuildVerificationCandidateAsync(
+ client,
+ update.Message,
+ currentUsername,
+ sentMessageId,
+ cancellationToken);
+
+ return candidate == null
+ ? (false, "匹配到的验证消息为空,无法执行 AI 识别", null)
+ : (true, null, candidate);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ var (summary, details) = MapTelegramException(ex);
+ var msg = string.IsNullOrWhiteSpace(details) ? summary : $"{summary}:{details}";
+ return (false, msg, null);
+ }
+ }
+
+ public async Task<(bool Success, string? Error)> ClickInlineButtonAsync(
+ int accountId,
+ ResolvedChatTarget target,
+ int messageId,
+ byte[] callbackData,
+ CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ if (callbackData == null || callbackData.Length == 0)
+ return (false, "按钮缺少 callback_data");
+
+ var client = await GetOrCreateConnectedClientAsync(accountId, cancellationToken);
+ cancellationToken.ThrowIfCancellationRequested();
+
+ _ = await client.Messages_GetBotCallbackAnswer(target.Peer, messageId, callbackData, null, false);
+ return (true, null);
+ }
+ catch (Exception ex) 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 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 ExtractInlineButtons(Message message)
+ {
+ if (message.reply_markup is not ReplyInlineMarkup markup)
+ return new List();
+
+ var result = new List();
+ var index = 0;
+ foreach (var row in markup.rows ?? Array.Empty())
+ {
+ var buttons = row?.buttons;
+ if (buttons == null || buttons.Length == 0)
+ continue;
+
+ foreach (var button in buttons)
+ {
+ if (button is KeyboardButtonCallback callback && callback.data is { Length: > 0 })
+ {
+ result.Add(new TelegramInlineButtonOption(index, callback.text ?? string.Empty, callback.data));
+ index++;
+ }
+ }
+ }
+
+ return result;
+ }
+
+ private async Task TryDownloadVerificationImageAsync(Client client, Message message, CancellationToken cancellationToken)
+ {
+ try
+ {
+ return message.media switch
+ {
+ MessageMediaPhoto { photo: Photo photo } => await DownloadPhotoAsJpegAsync(client, photo, cancellationToken),
+ MessageMediaDocument { document: Document document } => await DownloadDocumentPreviewAsJpegAsync(client, document, cancellationToken),
+ _ => null
+ };
+ }
+ catch (Exception ex)
+ {
+ _logger.LogDebug(ex, "Failed to download verification image from Telegram message {MessageId}", message.id);
+ return null;
+ }
+ }
+
+ private async Task DownloadPhotoAsJpegAsync(Client client, Photo photo, CancellationToken cancellationToken)
+ {
+ await using var raw = new MemoryStream();
+ await client.DownloadFileAsync(photo, raw, (PhotoSizeBase?)null);
+ raw.Position = 0;
+
+ await using var jpeg = await TelegramImageProcessor.PrepareStoredImageJpegAsync(raw, cancellationToken: cancellationToken);
+ return jpeg.ToArray();
+ }
+
+ private async Task DownloadDocumentPreviewAsJpegAsync(Client client, Document document, CancellationToken cancellationToken)
+ {
+ await using var raw = new MemoryStream();
+
+ var thumb = document.thumbs?.OfType().LastOrDefault();
+ if (thumb != null)
+ {
+ await client.DownloadFileAsync(document, raw, thumb);
+ }
+ else if ((document.mime_type ?? string.Empty).StartsWith("image/", StringComparison.OrdinalIgnoreCase))
+ {
+ await client.DownloadFileAsync(document, raw, (PhotoSizeBase?)null);
+ }
+ else
+ {
+ return null;
+ }
+
+ raw.Position = 0;
+ await using var jpeg = await TelegramImageProcessor.PrepareStoredImageJpegAsync(raw, cancellationToken: cancellationToken);
+ return jpeg.ToArray();
+ }
+
private static bool TryParseChatIdCandidate(string raw, out long normalizedId)
{
normalizedId = 0;
@@ -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
///
/// 将 Telegram 异常映射为可读的摘要和详情。
///
+ private static bool IsBotCallbackTimeout(Exception ex)
+ {
+ var msg = ex.Message ?? string.Empty;
+ return msg.Contains("BOT_RESPONSE_TIMEOUT", StringComparison.OrdinalIgnoreCase);
+ }
+
public static (string summary, string details) MapTelegramException(Exception ex)
{
var msg = ex.Message ?? string.Empty;
diff --git a/src/TelegramPanel.Core/Services/Telegram/BotTelegramService.cs b/src/TelegramPanel.Core/Services/Telegram/BotTelegramService.cs
index cd987cb..a97f813 100644
--- a/src/TelegramPanel.Core/Services/Telegram/BotTelegramService.cs
+++ b/src/TelegramPanel.Core/Services/Telegram/BotTelegramService.cs
@@ -499,34 +499,7 @@ public class BotTelegramService
if (string.IsNullOrWhiteSpace(fileName))
fileName = "photo.jpg";
- // Bot API setChatPhoto 需要 multipart 上传 InputFile。
- // 为了提高成功率:做“居中裁剪为正方形 + 缩放到 512x512 + JPEG 压缩”,避免原图过大/比例异常导致失败。
- await using var raw = new MemoryStream();
- if (fileStream.CanSeek)
- fileStream.Position = 0;
- await fileStream.CopyToAsync(raw, cancellationToken);
- raw.Position = 0;
-
- await using var encoded = new MemoryStream();
- try
- {
- using var image = await Image.LoadAsync(raw, cancellationToken);
- image.Mutate(x => x.AutoOrient());
-
- const int targetSize = 512;
- image.Mutate(x => x.Resize(new ResizeOptions
- {
- Mode = ResizeMode.Crop,
- Size = new Size(targetSize, targetSize)
- }));
-
- await image.SaveAsJpegAsync(encoded, new JpegEncoder { Quality = 85 }, cancellationToken);
- encoded.Position = 0;
- }
- catch (UnknownImageFormatException)
- {
- throw new InvalidOperationException("不支持的图片格式(建议使用 JPG/PNG)");
- }
+ await using var encoded = await TelegramImageProcessor.PrepareAvatarJpegAsync(fileStream, cancellationToken);
_ = await _api.CallWithFileAsync(
token: bot.Token,
diff --git a/src/TelegramPanel.Core/Services/Telegram/ChannelService.cs b/src/TelegramPanel.Core/Services/Telegram/ChannelService.cs
index 905b93d..7a809af 100644
--- a/src/TelegramPanel.Core/Services/Telegram/ChannelService.cs
+++ b/src/TelegramPanel.Core/Services/Telegram/ChannelService.cs
@@ -868,6 +868,45 @@ public class ChannelService : IChannelService
return true;
}
+ public async Task SetChannelPhotoAsync(
+ int accountId,
+ long channelId,
+ Stream fileStream,
+ string fileName,
+ CancellationToken cancellationToken = default)
+ {
+ var client = await GetOrCreateConnectedClientAsync(accountId, cancellationToken);
+ var channel = await GetChannelByIdAsync(client, channelId)
+ ?? throw new InvalidOperationException($"Channel {channelId} not found");
+
+ if (fileStream == null)
+ throw new ArgumentException("头像文件为空", nameof(fileStream));
+
+ fileName = (fileName ?? "channel_photo.jpg").Trim();
+ if (string.IsNullOrWhiteSpace(fileName))
+ fileName = "channel_photo.jpg";
+
+ try
+ {
+ await using var encoded = await TelegramImageProcessor.PrepareAvatarJpegAsync(fileStream, cancellationToken);
+ var uploaded = await client.UploadFileAsync(encoded, fileName);
+ if (uploaded == null)
+ throw new InvalidOperationException("频道头像上传失败:上传结果为空");
+
+ await client.Channels_EditPhoto(channel, new InputChatUploadedPhoto
+ {
+ flags = InputChatUploadedPhoto.Flags.has_file,
+ file = uploaded
+ });
+
+ return true;
+ }
+ catch (SixLabors.ImageSharp.UnknownImageFormatException)
+ {
+ throw new InvalidOperationException("不支持的图片格式(建议使用 JPG/PNG)");
+ }
+ }
+
public async Task KickUserAsync(int accountId, long channelId, string username, bool permanentBan = false)
{
var client = await GetOrCreateConnectedClientAsync(accountId);
diff --git a/src/TelegramPanel.Core/Services/Telegram/GroupService.cs b/src/TelegramPanel.Core/Services/Telegram/GroupService.cs
index dd2743a..9372efb 100644
--- a/src/TelegramPanel.Core/Services/Telegram/GroupService.cs
+++ b/src/TelegramPanel.Core/Services/Telegram/GroupService.cs
@@ -5,6 +5,7 @@ using TelegramPanel.Core.Interfaces;
using TelegramPanel.Core.Models;
using TelegramPanel.Core.Services;
using TL;
+using WTelegram;
namespace TelegramPanel.Core.Services.Telegram;
@@ -416,6 +417,121 @@ public class GroupService : IGroupService
return groups.FirstOrDefault(g => g.TelegramId == groupId);
}
+ public async Task UpdateGroupInfoAsync(int accountId, long groupId, string title, string? about)
+ {
+ var client = await GetOrCreateConnectedClientAsync(accountId);
+ var group = await GetEditableMegaGroupAsync(accountId, client, groupId, CancellationToken.None);
+
+ title = (title ?? string.Empty).Trim();
+ about = string.IsNullOrWhiteSpace(about) ? null : about.Trim();
+ if (string.IsNullOrWhiteSpace(title))
+ throw new ArgumentException("群组标题不能为空", nameof(title));
+
+ await client.Channels_EditTitle(group, title);
+ if (about != null)
+ await client.Messages_EditChatAbout(group, about);
+
+ return true;
+ }
+
+ public async Task SetGroupVisibilityAsync(int accountId, long groupId, bool isPublic, string? username = null)
+ {
+ var client = await GetOrCreateConnectedClientAsync(accountId);
+ var group = await GetEditableMegaGroupAsync(accountId, client, groupId, CancellationToken.None);
+
+ if (isPublic)
+ {
+ username = (username ?? string.Empty).Trim().TrimStart('@');
+ if (string.IsNullOrWhiteSpace(username))
+ throw new InvalidOperationException("公开群组需要设置用户名");
+
+ var current = (group.MainUsername ?? string.Empty).Trim();
+ if (string.Equals(current, username, StringComparison.OrdinalIgnoreCase))
+ return true;
+
+ var available = await client.Channels_CheckUsername(group, username);
+ if (!available)
+ throw new InvalidOperationException($"用户名 '{username}' 不可用");
+
+ try
+ {
+ await client.Channels_UpdateUsername(group, username);
+ }
+ catch (RpcException ex) when (ex.Message.Contains("USERNAME_NOT_MODIFIED", StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+ }
+ else
+ {
+ if (string.IsNullOrWhiteSpace(group.MainUsername))
+ return true;
+
+ await client.Channels_UpdateUsername(group, string.Empty);
+ }
+
+ return true;
+ }
+
+ public async Task SetGroupPhotoAsync(
+ int accountId,
+ long groupId,
+ Stream fileStream,
+ string fileName,
+ CancellationToken cancellationToken = default)
+ {
+ var client = await GetOrCreateConnectedClientAsync(accountId, cancellationToken);
+ var group = await GetEditableMegaGroupAsync(accountId, client, groupId, cancellationToken);
+
+ if (fileStream == null)
+ throw new ArgumentException("头像文件为空", nameof(fileStream));
+
+ fileName = (fileName ?? "group_photo.jpg").Trim();
+ if (string.IsNullOrWhiteSpace(fileName))
+ fileName = "group_photo.jpg";
+
+ try
+ {
+ await using var encoded = await TelegramImageProcessor.PrepareAvatarJpegAsync(fileStream, cancellationToken);
+ var uploaded = await client.UploadFileAsync(encoded, fileName);
+ if (uploaded == null)
+ throw new InvalidOperationException("群组头像上传失败:上传结果为空");
+
+ await client.Channels_EditPhoto(group, new InputChatUploadedPhoto
+ {
+ flags = InputChatUploadedPhoto.Flags.has_file,
+ file = uploaded
+ });
+
+ return true;
+ }
+ catch (SixLabors.ImageSharp.UnknownImageFormatException)
+ {
+ throw new InvalidOperationException("不支持的图片格式(建议使用 JPG/PNG)");
+ }
+ }
+
+ private async Task GetEditableMegaGroupAsync(int accountId, Client client, long groupId, CancellationToken cancellationToken)
+ {
+ var dialogs = await ExecuteTelegramRequestAsync(
+ accountId,
+ "拉取频道/群组对话列表",
+ () => client.Messages_GetAllDialogs(),
+ cancellationToken,
+ resetClientOnTimeout: true);
+
+ var normalizedId = groupId > 0 ? groupId : Math.Abs(groupId);
+ var mega = dialogs.chats.Values.OfType()
+ .FirstOrDefault(x => x.IsActive && !x.IsChannel && x.id == normalizedId);
+ if (mega != null)
+ return mega;
+
+ if (dialogs.chats.Values.OfType().Any(x => x.IsActive && x.id == normalizedId))
+ throw new InvalidOperationException("当前仅支持超级群组自动修改资料/公开状态");
+
+ throw new InvalidOperationException($"群组 {groupId} not found");
+ }
+
public async Task LeaveGroupAsync(int accountId, long groupId)
{
try
diff --git a/src/TelegramPanel.Core/Services/Telegram/TelegramAccountUpdateHub.cs b/src/TelegramPanel.Core/Services/Telegram/TelegramAccountUpdateHub.cs
new file mode 100644
index 0000000..17bb18f
--- /dev/null
+++ b/src/TelegramPanel.Core/Services/Telegram/TelegramAccountUpdateHub.cs
@@ -0,0 +1,92 @@
+using System.Collections.Concurrent;
+using TelegramPanel.Core.Models;
+
+namespace TelegramPanel.Core.Services.Telegram;
+
+///
+/// 聚合账号侧收到的 Telegram 消息更新,供后台任务按账号/聊天做短期等待。
+///
+public sealed class TelegramAccountUpdateHub
+{
+ private static readonly TimeSpan MessageRetention = TimeSpan.FromMinutes(10);
+ private const int MaxMessagesPerAccount = 200;
+
+ private readonly ConcurrentDictionary> _messagesByAccount = new();
+
+ public Task PublishAsync(TelegramAccountMessageUpdate update)
+ {
+ var queue = _messagesByAccount.GetOrAdd(update.AccountId, _ => new ConcurrentQueue());
+ queue.Enqueue(update);
+ TrimQueue(queue);
+ return Task.CompletedTask;
+ }
+
+ public IReadOnlyList GetRecentMessages(int accountId, DateTimeOffset sinceUtc)
+ {
+ if (!_messagesByAccount.TryGetValue(accountId, out var queue))
+ return Array.Empty();
+
+ return queue
+ .ToArray()
+ .Where(x => x.ReceivedAtUtc >= sinceUtc)
+ .OrderBy(x => x.ReceivedAtUtc)
+ .ToList();
+ }
+
+ public async Task WaitForAsync(
+ int accountId,
+ Func predicate,
+ DateTimeOffset sinceUtc,
+ TimeSpan timeout,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(predicate);
+
+ if (timeout <= TimeSpan.Zero)
+ timeout = TimeSpan.FromSeconds(1);
+
+ var deadline = DateTimeOffset.UtcNow + timeout;
+ var seen = new HashSet(StringComparer.Ordinal);
+
+ while (DateTimeOffset.UtcNow < deadline)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var items = GetRecentMessages(accountId, sinceUtc);
+ for (var index = items.Count - 1; index >= 0; index--)
+ {
+ var item = items[index];
+ var key = $"{item.Message.id}:{item.ReceivedAtUtc.UtcTicks}:{(item.IsEdited ? 1 : 0)}";
+ if (!seen.Add(key))
+ continue;
+
+ if (predicate(item))
+ return item;
+ }
+
+ var remaining = deadline - DateTimeOffset.UtcNow;
+ if (remaining <= TimeSpan.Zero)
+ break;
+
+ var delay = remaining < TimeSpan.FromMilliseconds(300)
+ ? remaining
+ : TimeSpan.FromMilliseconds(300);
+ await Task.Delay(delay, cancellationToken);
+ }
+
+ return null;
+ }
+
+ private static void TrimQueue(ConcurrentQueue queue)
+ {
+ var cutoff = DateTimeOffset.UtcNow - MessageRetention;
+
+ while (queue.TryPeek(out var item))
+ {
+ if (queue.Count <= MaxMessagesPerAccount && item.ReceivedAtUtc >= cutoff)
+ break;
+
+ queue.TryDequeue(out _);
+ }
+ }
+}
diff --git a/src/TelegramPanel.Core/Services/Telegram/TelegramClientPool.cs b/src/TelegramPanel.Core/Services/Telegram/TelegramClientPool.cs
index d48e398..e2ee618 100644
--- a/src/TelegramPanel.Core/Services/Telegram/TelegramClientPool.cs
+++ b/src/TelegramPanel.Core/Services/Telegram/TelegramClientPool.cs
@@ -5,6 +5,8 @@ using System.Threading;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using TelegramPanel.Core.Interfaces;
+using TelegramPanel.Core.Models;
+using TL;
using WTelegram;
namespace TelegramPanel.Core.Services.Telegram;
@@ -19,13 +21,19 @@ public class TelegramClientPool : ITelegramClientPool, IDisposable
private readonly ConcurrentDictionary _locks = new();
private readonly IConfiguration _configuration;
private readonly ILogger _logger;
+ private readonly TelegramAccountUpdateHub _updateHub;
private bool _disposed;
private static int _wtelegramLogConfigured;
+ private readonly ConcurrentDictionary _updateManagers = new();
- public TelegramClientPool(IConfiguration configuration, ILogger logger)
+ public TelegramClientPool(
+ IConfiguration configuration,
+ ILogger logger,
+ TelegramAccountUpdateHub updateHub)
{
_configuration = configuration;
_logger = logger;
+ _updateHub = updateHub;
ConfigureWTelegramLoggingOnce();
}
@@ -99,6 +107,10 @@ public class TelegramClientPool : ITelegramClientPool, IDisposable
var client = new Client(Config);
TryRebindApiIdForLoadedSession(client, apiId);
+ UpdateManager? updateManager = null;
+ updateManager = client.WithUpdateManager(update => HandleTelegramUpdateAsync(accountId, updateManager!, update));
+ _updateManagers[accountId] = updateManager;
+
// 设置日志回调
client.OnOther += (update) =>
{
@@ -149,6 +161,7 @@ public class TelegramClientPool : ITelegramClientPool, IDisposable
if (_clients.TryRemove(accountId, out var client))
{
_logger.LogInformation("Removing Telegram client for account {AccountId}", accountId);
+ _updateManagers.TryRemove(accountId, out _);
try
{
@@ -184,6 +197,7 @@ public class TelegramClientPool : ITelegramClientPool, IDisposable
}
_clients.Clear();
+ _updateManagers.Clear();
foreach (var lockObj in _locks.Values)
{
@@ -285,4 +299,89 @@ public class TelegramClientPool : ITelegramClientPool, IDisposable
_logger.LogDebug("WTelegram({Level}): {Message}", level, message);
};
}
+
+ private async Task HandleTelegramUpdateAsync(int accountId, UpdateManager updateManager, Update update)
+ {
+ switch (update)
+ {
+ case UpdateNewChannelMessage { message: Message message }:
+ await PublishMessageUpdateAsync(accountId, updateManager, message, isEdited: false);
+ return;
+ case UpdateNewMessage { message: Message message }:
+ await PublishMessageUpdateAsync(accountId, updateManager, message, isEdited: false);
+ return;
+ case UpdateEditChannelMessage { message: Message message }:
+ await PublishMessageUpdateAsync(accountId, updateManager, message, isEdited: true);
+ return;
+ case UpdateEditMessage { message: Message message }:
+ await PublishMessageUpdateAsync(accountId, updateManager, message, isEdited: true);
+ return;
+ }
+ }
+
+ private async Task PublishMessageUpdateAsync(int accountId, UpdateManager updateManager, Message message, bool isEdited)
+ {
+ try
+ {
+ var senderUserId = (message.from_id as PeerUser)?.user_id;
+ User? sender = null;
+ if (senderUserId.HasValue
+ && updateManager.Users.TryGetValue(senderUserId.Value, out var userBase)
+ && userBase is User user)
+ {
+ sender = user;
+ }
+
+ var buttons = ExtractInlineButtons(message.reply_markup);
+ var reply = message.reply_to as MessageReplyHeader;
+ var update = new TelegramAccountMessageUpdate(
+ AccountId: accountId,
+ ReceivedAtUtc: DateTimeOffset.UtcNow,
+ IsEdited: isEdited,
+ Message: message,
+ SenderUserId: senderUserId,
+ SenderUsername: sender?.username,
+ SenderIsBot: sender?.IsBot == true,
+ ReplyToMessageId: reply?.reply_to_msg_id,
+ ThreadId: reply?.reply_to_top_id,
+ Buttons: buttons);
+
+ await _updateHub.PublishAsync(update);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogDebug(ex, "Failed to publish Telegram message update (accountId={AccountId}, messageId={MessageId})", accountId, message.id);
+ }
+ }
+
+ private static IReadOnlyList ExtractInlineButtons(ReplyMarkup? replyMarkup)
+ {
+ if (replyMarkup is not ReplyInlineMarkup inlineMarkup || inlineMarkup.rows == null || inlineMarkup.rows.Length == 0)
+ return Array.Empty();
+
+ var result = new List();
+ var index = 0;
+ for (var rowIndex = 0; rowIndex < inlineMarkup.rows.Length; rowIndex++)
+ {
+ var row = inlineMarkup.rows[rowIndex];
+ var buttons = row?.buttons;
+ if (buttons == null || buttons.Length == 0)
+ continue;
+
+ for (var columnIndex = 0; columnIndex < buttons.Length; columnIndex++)
+ {
+ if (buttons[columnIndex] is not KeyboardButtonCallback callback)
+ continue;
+
+ var text = (callback.text ?? string.Empty).Trim();
+ if (text.Length == 0 || callback.data == null || callback.data.Length == 0)
+ continue;
+
+ result.Add(new TelegramInlineButton(index, rowIndex, columnIndex, text, callback.data));
+ index++;
+ }
+ }
+
+ return result;
+ }
}
diff --git a/src/TelegramPanel.Core/Services/Telegram/TelegramImageProcessor.cs b/src/TelegramPanel.Core/Services/Telegram/TelegramImageProcessor.cs
new file mode 100644
index 0000000..ad30c09
--- /dev/null
+++ b/src/TelegramPanel.Core/Services/Telegram/TelegramImageProcessor.cs
@@ -0,0 +1,73 @@
+using SixLabors.ImageSharp;
+using SixLabors.ImageSharp.Formats.Jpeg;
+using SixLabors.ImageSharp.Processing;
+
+namespace TelegramPanel.Core.Services.Telegram;
+
+///
+/// Telegram 图片处理工具。
+///
+public static class TelegramImageProcessor
+{
+ public static async Task PrepareAvatarJpegAsync(Stream fileStream, CancellationToken cancellationToken = default)
+ {
+ if (fileStream == null)
+ throw new ArgumentNullException(nameof(fileStream));
+
+ await using var raw = new MemoryStream();
+ if (fileStream.CanSeek)
+ fileStream.Position = 0;
+
+ await fileStream.CopyToAsync(raw, cancellationToken);
+ raw.Position = 0;
+
+ using var image = await Image.LoadAsync(raw, cancellationToken);
+ image.Mutate(x => x.AutoOrient());
+ image.Mutate(x => x.Resize(new ResizeOptions
+ {
+ Mode = ResizeMode.Crop,
+ Size = new Size(512, 512)
+ }));
+
+ var encoded = new MemoryStream();
+ await image.SaveAsJpegAsync(encoded, new JpegEncoder { Quality = 85 }, cancellationToken);
+ encoded.Position = 0;
+ return encoded;
+ }
+
+ public static async Task PrepareStoredImageJpegAsync(
+ Stream fileStream,
+ int maxDimension = 2048,
+ CancellationToken cancellationToken = default)
+ {
+ if (fileStream == null)
+ throw new ArgumentNullException(nameof(fileStream));
+
+ if (maxDimension < 256)
+ maxDimension = 256;
+
+ await using var raw = new MemoryStream();
+ if (fileStream.CanSeek)
+ fileStream.Position = 0;
+
+ await fileStream.CopyToAsync(raw, cancellationToken);
+ raw.Position = 0;
+
+ using var image = await Image.LoadAsync(raw, cancellationToken);
+ image.Mutate(x => x.AutoOrient());
+
+ if (image.Width > maxDimension || image.Height > maxDimension)
+ {
+ image.Mutate(x => x.Resize(new ResizeOptions
+ {
+ Mode = ResizeMode.Max,
+ Size = new Size(maxDimension, maxDimension)
+ }));
+ }
+
+ var encoded = new MemoryStream();
+ await image.SaveAsJpegAsync(encoded, new JpegEncoder { Quality = 88 }, cancellationToken);
+ encoded.Position = 0;
+ return encoded;
+ }
+}
diff --git a/src/TelegramPanel.Data/AppDbContext.cs b/src/TelegramPanel.Data/AppDbContext.cs
index 0a3ab3b..de95058 100644
--- a/src/TelegramPanel.Data/AppDbContext.cs
+++ b/src/TelegramPanel.Data/AppDbContext.cs
@@ -22,6 +22,9 @@ public class AppDbContext : DbContext
public DbSet Groups => Set();
public DbSet GroupCategories => Set();
public DbSet BatchTasks => Set();
+ public DbSet ScheduledTasks => Set();
+ public DbSet DataDictionaries => Set();
+ public DbSet DataDictionaryItems => Set();
public DbSet Bots => Set();
public DbSet BotChannels => Set();
public DbSet BotChannelCategories => Set();
@@ -72,6 +75,7 @@ public class AppDbContext : DbContext
entity.Property(e => e.Title).IsRequired().HasMaxLength(200);
entity.Property(e => e.Username).HasMaxLength(100);
entity.Property(e => e.About).HasMaxLength(1000);
+ entity.Property(e => e.SystemCreatedAtUtc);
entity.HasIndex(e => e.TelegramId).IsUnique();
entity.HasIndex(e => e.Username);
@@ -126,6 +130,7 @@ public class AppDbContext : DbContext
entity.Property(e => e.Title).IsRequired().HasMaxLength(200);
entity.Property(e => e.Username).HasMaxLength(100);
entity.Property(e => e.About).HasMaxLength(1000);
+ entity.Property(e => e.SystemCreatedAtUtc);
entity.HasIndex(e => e.TelegramId).IsUnique();
entity.HasIndex(e => e.Username);
@@ -184,6 +189,52 @@ public class AppDbContext : DbContext
entity.HasIndex(e => e.CreatedAt);
});
+ // ScheduledTask配置
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasKey(e => e.Id);
+ entity.Property(e => e.TaskType).IsRequired().HasMaxLength(100);
+ entity.Property(e => e.Status).IsRequired().HasMaxLength(20);
+ entity.Property(e => e.CronExpression).IsRequired().HasMaxLength(100);
+ entity.Property(e => e.OwnedAssetScopeId).HasMaxLength(120);
+
+ entity.HasIndex(e => e.Status);
+ entity.HasIndex(e => e.NextRunAtUtc);
+ entity.HasIndex(e => e.CreatedAt);
+ });
+
+ // DataDictionary配置
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasKey(e => e.Id);
+ entity.Property(e => e.Name).IsRequired().HasMaxLength(100);
+ entity.Property(e => e.DisplayName).IsRequired().HasMaxLength(100);
+ entity.Property(e => e.Description).HasMaxLength(500);
+ entity.Property(e => e.Type).IsRequired().HasMaxLength(20);
+ entity.Property(e => e.ReadMode).IsRequired().HasMaxLength(20);
+
+ entity.HasIndex(e => e.Name).IsUnique();
+ entity.HasIndex(e => e.Type);
+ entity.HasIndex(e => e.IsEnabled);
+ });
+
+ // DataDictionaryItem配置
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasKey(e => e.Id);
+ entity.Property(e => e.TextValue).HasMaxLength(4000);
+ entity.Property(e => e.AssetPath).HasMaxLength(500);
+ entity.Property(e => e.FileName).HasMaxLength(255);
+
+ entity.HasIndex(e => e.DataDictionaryId);
+ entity.HasIndex(e => new { e.DataDictionaryId, e.SortOrder });
+
+ entity.HasOne(e => e.Dictionary)
+ .WithMany(d => d.Items)
+ .HasForeignKey(e => e.DataDictionaryId)
+ .OnDelete(DeleteBehavior.Cascade);
+ });
+
// Bot配置
modelBuilder.Entity(entity =>
{
diff --git a/src/TelegramPanel.Data/Entities/Channel.cs b/src/TelegramPanel.Data/Entities/Channel.cs
index d76deda..88b9439 100644
--- a/src/TelegramPanel.Data/Entities/Channel.cs
+++ b/src/TelegramPanel.Data/Entities/Channel.cs
@@ -16,6 +16,7 @@ public class Channel
public int? CreatorAccountId { get; set; }
public int? GroupId { get; set; }
public DateTime? CreatedAt { get; set; }
+ public DateTime? SystemCreatedAtUtc { get; set; }
public DateTime SyncedAt { get; set; } = DateTime.UtcNow;
// 导航属性
diff --git a/src/TelegramPanel.Data/Entities/DataDictionary.cs b/src/TelegramPanel.Data/Entities/DataDictionary.cs
new file mode 100644
index 0000000..3daa57e
--- /dev/null
+++ b/src/TelegramPanel.Data/Entities/DataDictionary.cs
@@ -0,0 +1,38 @@
+namespace TelegramPanel.Data.Entities;
+
+///
+/// 数据字典类型常量。
+///
+public static class DataDictionaryTypes
+{
+ public const string Text = "text";
+ public const string Image = "image";
+}
+
+///
+/// 数据字典读取模式常量。
+///
+public static class DataDictionaryReadModes
+{
+ public const string Random = "random";
+ public const string Queue = "queue";
+}
+
+///
+/// 数据字典。
+///
+public class DataDictionary
+{
+ public int Id { get; set; }
+ public string Name { get; set; } = null!;
+ public string DisplayName { get; set; } = null!;
+ public string? Description { get; set; }
+ public string Type { get; set; } = DataDictionaryTypes.Text;
+ public string ReadMode { get; set; } = DataDictionaryReadModes.Random;
+ public bool IsEnabled { get; set; } = true;
+ public int NextIndex { get; set; }
+ public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
+ public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
+
+ public ICollection Items { get; set; } = new List();
+}
diff --git a/src/TelegramPanel.Data/Entities/DataDictionaryItem.cs b/src/TelegramPanel.Data/Entities/DataDictionaryItem.cs
new file mode 100644
index 0000000..9ab6045
--- /dev/null
+++ b/src/TelegramPanel.Data/Entities/DataDictionaryItem.cs
@@ -0,0 +1,18 @@
+namespace TelegramPanel.Data.Entities;
+
+///
+/// 数据字典项。
+///
+public class DataDictionaryItem
+{
+ public int Id { get; set; }
+ public int DataDictionaryId { get; set; }
+ public string? TextValue { get; set; }
+ public string? AssetPath { get; set; }
+ public string? FileName { get; set; }
+ public int SortOrder { get; set; }
+ public bool IsEnabled { get; set; } = true;
+ public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
+
+ public DataDictionary Dictionary { get; set; } = null!;
+}
diff --git a/src/TelegramPanel.Data/Entities/Group.cs b/src/TelegramPanel.Data/Entities/Group.cs
index e34eb8e..a78c5f1 100644
--- a/src/TelegramPanel.Data/Entities/Group.cs
+++ b/src/TelegramPanel.Data/Entities/Group.cs
@@ -15,6 +15,7 @@ public class Group
public int? CreatorAccountId { get; set; }
public int? CategoryId { get; set; }
public DateTime? CreatedAt { get; set; }
+ public DateTime? SystemCreatedAtUtc { get; set; }
public DateTime SyncedAt { get; set; } = DateTime.UtcNow;
// 导航属性
diff --git a/src/TelegramPanel.Data/Entities/ScheduledTask.cs b/src/TelegramPanel.Data/Entities/ScheduledTask.cs
new file mode 100644
index 0000000..f344122
--- /dev/null
+++ b/src/TelegramPanel.Data/Entities/ScheduledTask.cs
@@ -0,0 +1,29 @@
+namespace TelegramPanel.Data.Entities;
+
+///
+/// 计划任务状态常量。
+///
+public static class ScheduledTaskStatuses
+{
+ public const string Enabled = "enabled";
+ public const string Paused = "paused";
+}
+
+///
+/// Cron 计划任务定义。
+///
+public class ScheduledTask
+{
+ public int Id { get; set; }
+ public string TaskType { get; set; } = null!;
+ public string Status { get; set; } = ScheduledTaskStatuses.Enabled;
+ public int Total { get; set; }
+ public string? ConfigJson { get; set; }
+ public string CronExpression { get; set; } = null!;
+ public DateTime? NextRunAtUtc { get; set; }
+ public DateTime? LastRunAtUtc { get; set; }
+ public int? LastBatchTaskId { get; set; }
+ public string? OwnedAssetScopeId { get; set; }
+ public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
+ public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
+}
diff --git a/src/TelegramPanel.Data/Migrations/20260309132915_AddScheduledTasksAndDataDictionaries.Designer.cs b/src/TelegramPanel.Data/Migrations/20260309132915_AddScheduledTasksAndDataDictionaries.Designer.cs
new file mode 100644
index 0000000..32d0188
--- /dev/null
+++ b/src/TelegramPanel.Data/Migrations/20260309132915_AddScheduledTasksAndDataDictionaries.Designer.cs
@@ -0,0 +1,917 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using TelegramPanel.Data;
+
+#nullable disable
+
+namespace TelegramPanel.Data.Migrations
+{
+ [DbContext(typeof(AppDbContext))]
+ [Migration("20260309132915_AddScheduledTasksAndDataDictionaries")]
+ partial class AddScheduledTasksAndDataDictionaries
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder.HasAnnotation("ProductVersion", "8.0.24");
+
+ modelBuilder.Entity("TelegramPanel.Data.Entities.Account", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("ApiHash")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("TEXT");
+
+ b.Property("ApiId")
+ .HasColumnType("INTEGER");
+
+ b.Property("CategoryId")
+ .HasColumnType("INTEGER");
+
+ b.Property("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("EstimatedRegistrationAt")
+ .HasColumnType("TEXT");
+
+ b.Property("EstimatedRegistrationCheckedAtUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("IsActive")
+ .HasColumnType("INTEGER");
+
+ b.Property("LastLoginAt")
+ .HasColumnType("TEXT");
+
+ b.Property("LastSyncAt")
+ .HasColumnType("TEXT");
+
+ b.Property("Nickname")
+ .HasMaxLength(100)
+ .HasColumnType("TEXT");
+
+ b.Property("Phone")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("TEXT");
+
+ b.Property("SessionPath")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("TEXT");
+
+ b.Property("TelegramStatusCheckedAtUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("TelegramStatusDetails")
+ .HasMaxLength(2000)
+ .HasColumnType("TEXT");
+
+ b.Property("TelegramStatusOk")
+ .HasColumnType("INTEGER");
+
+ b.Property("TelegramStatusSummary")
+ .HasMaxLength(200)
+ .HasColumnType("TEXT");
+
+ b.Property("TwoFactorPassword")
+ .HasColumnType("TEXT");
+
+ b.Property("UserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Username")
+ .HasMaxLength(100)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CategoryId");
+
+ b.HasIndex("Phone")
+ .IsUnique();
+
+ b.HasIndex("UserId");
+
+ b.ToTable("Accounts");
+ });
+
+ modelBuilder.Entity("TelegramPanel.Data.Entities.AccountCategory", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("Color")
+ .HasMaxLength(20)
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("Description")
+ .HasMaxLength(500)
+ .HasColumnType("TEXT");
+
+ b.Property("ExcludeFromOperations")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasDefaultValue(false);
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Name")
+ .IsUnique();
+
+ b.ToTable("AccountCategories");
+ });
+
+ modelBuilder.Entity("TelegramPanel.Data.Entities.AccountChannel", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AccountId")
+ .HasColumnType("INTEGER");
+
+ b.Property("ChannelId")
+ .HasColumnType("INTEGER");
+
+ b.Property("IsAdmin")
+ .HasColumnType("INTEGER");
+
+ b.Property("IsCreator")
+ .HasColumnType("INTEGER");
+
+ b.Property("SyncedAt")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ChannelId");
+
+ b.HasIndex("AccountId", "ChannelId")
+ .IsUnique();
+
+ b.HasIndex("ChannelId", "IsCreator", "IsAdmin");
+
+ b.ToTable("AccountChannels");
+ });
+
+ modelBuilder.Entity("TelegramPanel.Data.Entities.AccountGroup", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AccountId")
+ .HasColumnType("INTEGER");
+
+ b.Property("GroupId")
+ .HasColumnType("INTEGER");
+
+ b.Property("IsAdmin")
+ .HasColumnType("INTEGER");
+
+ b.Property("IsCreator")
+ .HasColumnType("INTEGER");
+
+ b.Property("SyncedAt")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("GroupId");
+
+ b.HasIndex("AccountId", "GroupId")
+ .IsUnique();
+
+ b.HasIndex("GroupId", "IsCreator", "IsAdmin");
+
+ b.ToTable("AccountGroups");
+ });
+
+ modelBuilder.Entity("TelegramPanel.Data.Entities.BatchTask", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("Completed")
+ .HasColumnType("INTEGER");
+
+ b.Property("CompletedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("Config")
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("Failed")
+ .HasColumnType("INTEGER");
+
+ b.Property("StartedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("TEXT");
+
+ b.Property("TaskType")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("TEXT");
+
+ b.Property("Total")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CreatedAt");
+
+ b.HasIndex("Status");
+
+ b.ToTable("BatchTasks");
+ });
+
+ modelBuilder.Entity("TelegramPanel.Data.Entities.Bot", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("IsActive")
+ .HasColumnType("INTEGER");
+
+ b.Property("LastSyncAt")
+ .HasColumnType("TEXT");
+
+ b.Property("LastUpdateId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("TEXT");
+
+ b.Property("Token")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("TEXT");
+
+ b.Property("Username")
+ .HasMaxLength(100)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Name")
+ .IsUnique();
+
+ b.HasIndex("Username");
+
+ b.ToTable("Bots");
+ });
+
+ modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannel", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("About")
+ .HasMaxLength(1000)
+ .HasColumnType("TEXT");
+
+ b.Property("AccessHash")
+ .HasColumnType("INTEGER");
+
+ b.Property("CategoryId")
+ .HasColumnType("INTEGER");
+
+ b.Property("ChannelStatusCheckedAtUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("ChannelStatusError")
+ .HasMaxLength(500)
+ .HasColumnType("TEXT");
+
+ b.Property("ChannelStatusOk")
+ .HasColumnType("INTEGER");
+
+ b.Property("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("IsBroadcast")
+ .HasColumnType("INTEGER");
+
+ b.Property("MemberCount")
+ .HasColumnType("INTEGER");
+
+ b.Property("SyncedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("TelegramId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("TEXT");
+
+ b.Property("Username")
+ .HasMaxLength(100)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CategoryId");
+
+ b.HasIndex("ChannelStatusOk");
+
+ b.HasIndex("TelegramId")
+ .IsUnique();
+
+ b.HasIndex("Username");
+
+ b.ToTable("BotChannels");
+ });
+
+ modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannelCategory", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("Description")
+ .HasMaxLength(500)
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Name")
+ .IsUnique();
+
+ b.ToTable("BotChannelCategories");
+ });
+
+ modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannelMember", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("BotChannelId")
+ .HasColumnType("INTEGER");
+
+ b.Property("BotId")
+ .HasColumnType("INTEGER");
+
+ b.Property("SyncedAt")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("BotChannelId");
+
+ b.HasIndex("BotId");
+
+ b.HasIndex("BotId", "BotChannelId")
+ .IsUnique();
+
+ b.ToTable("BotChannelMembers");
+ });
+
+ modelBuilder.Entity("TelegramPanel.Data.Entities.Channel", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("About")
+ .HasMaxLength(1000)
+ .HasColumnType("TEXT");
+
+ b.Property("AccessHash")
+ .HasColumnType("INTEGER");
+
+ b.Property("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("CreatorAccountId")
+ .HasColumnType("INTEGER");
+
+ b.Property("GroupId")
+ .HasColumnType("INTEGER");
+
+ b.Property("IsBroadcast")
+ .HasColumnType("INTEGER");
+
+ b.Property("MemberCount")
+ .HasColumnType("INTEGER");
+
+ b.Property("SyncedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("SystemCreatedAtUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("TelegramId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("TEXT");
+
+ b.Property("Username")
+ .HasMaxLength(100)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CreatorAccountId");
+
+ b.HasIndex("GroupId");
+
+ b.HasIndex("SyncedAt");
+
+ b.HasIndex("TelegramId")
+ .IsUnique();
+
+ b.HasIndex("Username");
+
+ b.ToTable("Channels");
+ });
+
+ modelBuilder.Entity("TelegramPanel.Data.Entities.ChannelGroup", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("Description")
+ .HasMaxLength(500)
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Name")
+ .IsUnique();
+
+ b.ToTable("ChannelGroups");
+ });
+
+ modelBuilder.Entity("TelegramPanel.Data.Entities.DataDictionary", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("Description")
+ .HasMaxLength(500)
+ .HasColumnType("TEXT");
+
+ b.Property("DisplayName")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("TEXT");
+
+ b.Property("IsEnabled")
+ .HasColumnType("INTEGER");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("TEXT");
+
+ b.Property("NextIndex")
+ .HasColumnType("INTEGER");
+
+ b.Property("ReadMode")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("TEXT");
+
+ b.Property("Type")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("TEXT");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("IsEnabled");
+
+ b.HasIndex("Name")
+ .IsUnique();
+
+ b.HasIndex("Type");
+
+ b.ToTable("DataDictionaries");
+ });
+
+ modelBuilder.Entity("TelegramPanel.Data.Entities.DataDictionaryItem", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AssetPath")
+ .HasMaxLength(500)
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("DataDictionaryId")
+ .HasColumnType("INTEGER");
+
+ b.Property("FileName")
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
+
+ b.Property("IsEnabled")
+ .HasColumnType("INTEGER");
+
+ b.Property("SortOrder")
+ .HasColumnType("INTEGER");
+
+ b.Property("TextValue")
+ .HasMaxLength(4000)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DataDictionaryId");
+
+ b.HasIndex("DataDictionaryId", "SortOrder");
+
+ b.ToTable("DataDictionaryItems");
+ });
+
+ modelBuilder.Entity("TelegramPanel.Data.Entities.Group", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("About")
+ .HasMaxLength(1000)
+ .HasColumnType("TEXT");
+
+ b.Property("AccessHash")
+ .HasColumnType("INTEGER");
+
+ b.Property("CategoryId")
+ .HasColumnType("INTEGER");
+
+ b.Property("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("CreatorAccountId")
+ .HasColumnType("INTEGER");
+
+ b.Property("MemberCount")
+ .HasColumnType("INTEGER");
+
+ b.Property("SyncedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("SystemCreatedAtUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("TelegramId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("TEXT");
+
+ b.Property("Username")
+ .HasMaxLength(100)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CategoryId");
+
+ b.HasIndex("CreatorAccountId");
+
+ b.HasIndex("SyncedAt");
+
+ b.HasIndex("TelegramId")
+ .IsUnique();
+
+ b.HasIndex("Username");
+
+ b.ToTable("Groups");
+ });
+
+ modelBuilder.Entity("TelegramPanel.Data.Entities.GroupCategory", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("Description")
+ .HasMaxLength(500)
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Name")
+ .IsUnique();
+
+ b.ToTable("GroupCategories");
+ });
+
+ modelBuilder.Entity("TelegramPanel.Data.Entities.ScheduledTask", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("ConfigJson")
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("CronExpression")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("TEXT");
+
+ b.Property("LastBatchTaskId")
+ .HasColumnType("INTEGER");
+
+ b.Property("LastRunAtUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("NextRunAtUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("OwnedAssetScopeId")
+ .HasMaxLength(120)
+ .HasColumnType("TEXT");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("TEXT");
+
+ b.Property("TaskType")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("TEXT");
+
+ b.Property("Total")
+ .HasColumnType("INTEGER");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CreatedAt");
+
+ b.HasIndex("NextRunAtUtc");
+
+ b.HasIndex("Status");
+
+ b.ToTable("ScheduledTasks");
+ });
+
+ modelBuilder.Entity("TelegramPanel.Data.Entities.Account", b =>
+ {
+ b.HasOne("TelegramPanel.Data.Entities.AccountCategory", "Category")
+ .WithMany("Accounts")
+ .HasForeignKey("CategoryId")
+ .OnDelete(DeleteBehavior.SetNull);
+
+ b.Navigation("Category");
+ });
+
+ modelBuilder.Entity("TelegramPanel.Data.Entities.AccountChannel", b =>
+ {
+ b.HasOne("TelegramPanel.Data.Entities.Account", "Account")
+ .WithMany("AccountChannels")
+ .HasForeignKey("AccountId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("TelegramPanel.Data.Entities.Channel", "Channel")
+ .WithMany("AccountChannels")
+ .HasForeignKey("ChannelId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Account");
+
+ b.Navigation("Channel");
+ });
+
+ modelBuilder.Entity("TelegramPanel.Data.Entities.AccountGroup", b =>
+ {
+ b.HasOne("TelegramPanel.Data.Entities.Account", "Account")
+ .WithMany("AccountGroups")
+ .HasForeignKey("AccountId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("TelegramPanel.Data.Entities.Group", "Group")
+ .WithMany("AccountGroups")
+ .HasForeignKey("GroupId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Account");
+
+ b.Navigation("Group");
+ });
+
+ modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannel", b =>
+ {
+ b.HasOne("TelegramPanel.Data.Entities.BotChannelCategory", "Category")
+ .WithMany("Channels")
+ .HasForeignKey("CategoryId")
+ .OnDelete(DeleteBehavior.SetNull);
+
+ b.Navigation("Category");
+ });
+
+ modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannelMember", b =>
+ {
+ b.HasOne("TelegramPanel.Data.Entities.BotChannel", "BotChannel")
+ .WithMany("Members")
+ .HasForeignKey("BotChannelId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("TelegramPanel.Data.Entities.Bot", "Bot")
+ .WithMany("ChannelMembers")
+ .HasForeignKey("BotId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Bot");
+
+ b.Navigation("BotChannel");
+ });
+
+ modelBuilder.Entity("TelegramPanel.Data.Entities.Channel", b =>
+ {
+ b.HasOne("TelegramPanel.Data.Entities.Account", "CreatorAccount")
+ .WithMany("Channels")
+ .HasForeignKey("CreatorAccountId")
+ .OnDelete(DeleteBehavior.SetNull);
+
+ b.HasOne("TelegramPanel.Data.Entities.ChannelGroup", "Group")
+ .WithMany("Channels")
+ .HasForeignKey("GroupId")
+ .OnDelete(DeleteBehavior.SetNull);
+
+ b.Navigation("CreatorAccount");
+
+ b.Navigation("Group");
+ });
+
+ modelBuilder.Entity("TelegramPanel.Data.Entities.DataDictionaryItem", b =>
+ {
+ b.HasOne("TelegramPanel.Data.Entities.DataDictionary", "Dictionary")
+ .WithMany("Items")
+ .HasForeignKey("DataDictionaryId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Dictionary");
+ });
+
+ modelBuilder.Entity("TelegramPanel.Data.Entities.Group", b =>
+ {
+ b.HasOne("TelegramPanel.Data.Entities.GroupCategory", "Category")
+ .WithMany("Groups")
+ .HasForeignKey("CategoryId")
+ .OnDelete(DeleteBehavior.SetNull);
+
+ b.HasOne("TelegramPanel.Data.Entities.Account", "CreatorAccount")
+ .WithMany("Groups")
+ .HasForeignKey("CreatorAccountId")
+ .OnDelete(DeleteBehavior.SetNull);
+
+ b.Navigation("Category");
+
+ b.Navigation("CreatorAccount");
+ });
+
+ modelBuilder.Entity("TelegramPanel.Data.Entities.Account", b =>
+ {
+ b.Navigation("AccountChannels");
+
+ b.Navigation("AccountGroups");
+
+ b.Navigation("Channels");
+
+ b.Navigation("Groups");
+ });
+
+ modelBuilder.Entity("TelegramPanel.Data.Entities.AccountCategory", b =>
+ {
+ b.Navigation("Accounts");
+ });
+
+ modelBuilder.Entity("TelegramPanel.Data.Entities.Bot", b =>
+ {
+ b.Navigation("ChannelMembers");
+ });
+
+ modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannel", b =>
+ {
+ b.Navigation("Members");
+ });
+
+ modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannelCategory", b =>
+ {
+ b.Navigation("Channels");
+ });
+
+ modelBuilder.Entity("TelegramPanel.Data.Entities.Channel", b =>
+ {
+ b.Navigation("AccountChannels");
+ });
+
+ modelBuilder.Entity("TelegramPanel.Data.Entities.ChannelGroup", b =>
+ {
+ b.Navigation("Channels");
+ });
+
+ modelBuilder.Entity("TelegramPanel.Data.Entities.DataDictionary", b =>
+ {
+ b.Navigation("Items");
+ });
+
+ modelBuilder.Entity("TelegramPanel.Data.Entities.Group", b =>
+ {
+ b.Navigation("AccountGroups");
+ });
+
+ modelBuilder.Entity("TelegramPanel.Data.Entities.GroupCategory", b =>
+ {
+ b.Navigation("Groups");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/TelegramPanel.Data/Migrations/20260309132915_AddScheduledTasksAndDataDictionaries.cs b/src/TelegramPanel.Data/Migrations/20260309132915_AddScheduledTasksAndDataDictionaries.cs
new file mode 100644
index 0000000..91c5481
--- /dev/null
+++ b/src/TelegramPanel.Data/Migrations/20260309132915_AddScheduledTasksAndDataDictionaries.cs
@@ -0,0 +1,158 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace TelegramPanel.Data.Migrations
+{
+ ///
+ public partial class AddScheduledTasksAndDataDictionaries : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AddColumn(
+ name: "SystemCreatedAtUtc",
+ table: "Groups",
+ type: "TEXT",
+ nullable: true);
+
+ migrationBuilder.AddColumn(
+ name: "SystemCreatedAtUtc",
+ table: "Channels",
+ type: "TEXT",
+ nullable: true);
+
+ migrationBuilder.CreateTable(
+ name: "DataDictionaries",
+ columns: table => new
+ {
+ Id = table.Column(type: "INTEGER", nullable: false)
+ .Annotation("Sqlite:Autoincrement", true),
+ Name = table.Column(type: "TEXT", maxLength: 100, nullable: false),
+ DisplayName = table.Column(type: "TEXT", maxLength: 100, nullable: false),
+ Description = table.Column(type: "TEXT", maxLength: 500, nullable: true),
+ Type = table.Column(type: "TEXT", maxLength: 20, nullable: false),
+ ReadMode = table.Column(type: "TEXT", maxLength: 20, nullable: false),
+ IsEnabled = table.Column(type: "INTEGER", nullable: false),
+ NextIndex = table.Column(type: "INTEGER", nullable: false),
+ CreatedAt = table.Column(type: "TEXT", nullable: false),
+ UpdatedAt = table.Column(type: "TEXT", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_DataDictionaries", x => x.Id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "ScheduledTasks",
+ columns: table => new
+ {
+ Id = table.Column(type: "INTEGER", nullable: false)
+ .Annotation("Sqlite:Autoincrement", true),
+ TaskType = table.Column(type: "TEXT", maxLength: 100, nullable: false),
+ Status = table.Column(type: "TEXT", maxLength: 20, nullable: false),
+ Total = table.Column(type: "INTEGER", nullable: false),
+ ConfigJson = table.Column(type: "TEXT", nullable: true),
+ CronExpression = table.Column(type: "TEXT", maxLength: 100, nullable: false),
+ NextRunAtUtc = table.Column(type: "TEXT", nullable: true),
+ LastRunAtUtc = table.Column(type: "TEXT", nullable: true),
+ LastBatchTaskId = table.Column(type: "INTEGER", nullable: true),
+ OwnedAssetScopeId = table.Column(type: "TEXT", maxLength: 120, nullable: true),
+ CreatedAt = table.Column(type: "TEXT", nullable: false),
+ UpdatedAt = table.Column(type: "TEXT", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_ScheduledTasks", x => x.Id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "DataDictionaryItems",
+ columns: table => new
+ {
+ Id = table.Column(type: "INTEGER", nullable: false)
+ .Annotation("Sqlite:Autoincrement", true),
+ DataDictionaryId = table.Column(type: "INTEGER", nullable: false),
+ TextValue = table.Column(type: "TEXT", maxLength: 4000, nullable: true),
+ AssetPath = table.Column(type: "TEXT", maxLength: 500, nullable: true),
+ FileName = table.Column(type: "TEXT", maxLength: 255, nullable: true),
+ SortOrder = table.Column(type: "INTEGER", nullable: false),
+ IsEnabled = table.Column(type: "INTEGER", nullable: false),
+ CreatedAt = table.Column(type: "TEXT", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_DataDictionaryItems", x => x.Id);
+ table.ForeignKey(
+ name: "FK_DataDictionaryItems_DataDictionaries_DataDictionaryId",
+ column: x => x.DataDictionaryId,
+ principalTable: "DataDictionaries",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_DataDictionaries_IsEnabled",
+ table: "DataDictionaries",
+ column: "IsEnabled");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_DataDictionaries_Name",
+ table: "DataDictionaries",
+ column: "Name",
+ unique: true);
+
+ migrationBuilder.CreateIndex(
+ name: "IX_DataDictionaries_Type",
+ table: "DataDictionaries",
+ column: "Type");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_DataDictionaryItems_DataDictionaryId",
+ table: "DataDictionaryItems",
+ column: "DataDictionaryId");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_DataDictionaryItems_DataDictionaryId_SortOrder",
+ table: "DataDictionaryItems",
+ columns: new[] { "DataDictionaryId", "SortOrder" });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_ScheduledTasks_CreatedAt",
+ table: "ScheduledTasks",
+ column: "CreatedAt");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_ScheduledTasks_NextRunAtUtc",
+ table: "ScheduledTasks",
+ column: "NextRunAtUtc");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_ScheduledTasks_Status",
+ table: "ScheduledTasks",
+ column: "Status");
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "DataDictionaryItems");
+
+ migrationBuilder.DropTable(
+ name: "ScheduledTasks");
+
+ migrationBuilder.DropTable(
+ name: "DataDictionaries");
+
+ migrationBuilder.DropColumn(
+ name: "SystemCreatedAtUtc",
+ table: "Groups");
+
+ migrationBuilder.DropColumn(
+ name: "SystemCreatedAtUtc",
+ table: "Channels");
+ }
+ }
+}
diff --git a/src/TelegramPanel.Data/Migrations/AppDbContextModelSnapshot.cs b/src/TelegramPanel.Data/Migrations/AppDbContextModelSnapshot.cs
index 826e1c4..36e4691 100644
--- a/src/TelegramPanel.Data/Migrations/AppDbContextModelSnapshot.cs
+++ b/src/TelegramPanel.Data/Migrations/AppDbContextModelSnapshot.cs
@@ -439,6 +439,9 @@ namespace TelegramPanel.Data.Migrations
b.Property("SyncedAt")
.HasColumnType("TEXT");
+ b.Property("SystemCreatedAtUtc")
+ .HasColumnType("TEXT");
+
b.Property("TelegramId")
.HasColumnType("INTEGER");
@@ -493,6 +496,99 @@ namespace TelegramPanel.Data.Migrations
b.ToTable("ChannelGroups");
});
+ modelBuilder.Entity("TelegramPanel.Data.Entities.DataDictionary", b =>
+ {
+ b.Property