diff --git a/Directory.Build.props b/Directory.Build.props
index 7b821de..1902e39 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -5,10 +5,10 @@
- 1.31.4
- 1.31.4.0
- 1.31.4.0
- 1.31.4
+ 1.31.5
+ 1.31.5.0
+ 1.31.5.0
+ 1.31.5
Telegram Panel Contributors
diff --git a/README.md b/README.md
index bfe6487..175be6a 100644
--- a/README.md
+++ b/README.md
@@ -28,9 +28,22 @@
- 🔐 2FA 管理:单个/批量修改二级密码;绑定/换绑找回邮箱(支持对接 Cloud Mail 自动收码确认)
- 🧩 模块化扩展:任务 / API / UI 可安装扩展(见 `docs/developer/modules.md`)
+## 近期新增功能(dev)
+
+- 🧠 任务中心增强:持续任务支持暂停、编辑、重新运行;任务列表区分执行中与历史任务;支持“仅清理历史任务”与“历史任务保留上限”自动清理
+- 💬 持续活跃任务升级:新增账号持续活跃(群组/频道)能力,支持多分类账号、随机文案、秒级发送间隔、持续运行配置
+- 🔄 同步体验优化:手动“立即同步”改为后台任务执行,可在任务中心跟踪进度;同步链路补充超时保护与状态持久化
+- 👤 账号列表增强:新增注册时间展示,并可直接从账号操作中查看该账号已加入的频道和群组
+- 📺 频道管理升级:频道列表改为面向“已加入频道”视角,不再只显示系统创建频道;支持按账号、角色、分类筛选,支持查看频道内的本系统账号
+- 👥 群组管理补齐:新增群组创建页、群组分类、批量操作与列表能力补齐;群组列表同样支持按账号、角色、分类筛选,并支持查看群组内的本系统账号
+- 🔗 多账号关系可视化:同一频道/群组可绑定多个系统账号,频道/群组列表与详情页都可查看当前关联的系统账号、角色与状态
+- 🚪 真实退出/解散能力:频道与群组支持单个/批量退出;支持解散频道/群组,且解散前需后台密码确认
+- 🧹 数据准确性修正:修复频道列表混入群组的问题,并优化账号、频道、群组关系同步后的本地展示逻辑
+- ⚡ 数据层优化:补充频道/群组查询与关系索引,提升几百账号、上千频道/群组场景下的筛选与加载性能
+
## TODO(规划)
-- [ ] 一键退群/退订、订阅(频道/群组)
+- [x] 一键退群/退订、订阅(频道/群组)
- [ ] 一键清空联系人
- [ ] 批量手机号验证码重新登录(用于刷新会话 session)
- [ ] 手机号注册:未注册号支持完整注册流程(姓名/可选邮箱/邮箱验证码等)
@@ -40,7 +53,7 @@
- [ ] 多 API:支持账号分类绑定 ApiId/ApiHash
- [ ] 定时创建频道、定时公开频道
- [ ] 定时刷粉丝:对接刷粉 API(通用适配结构),通过适配模块对接多家刷粉平台
-- [ ] 群聊定时发言养号
+- [x] 群聊定时发言养号
## 快速开始
diff --git a/src/TelegramPanel.Core/BatchTasks/BatchTaskTypes.cs b/src/TelegramPanel.Core/BatchTasks/BatchTaskTypes.cs
index 14cd765..ec2d94d 100644
--- a/src/TelegramPanel.Core/BatchTasks/BatchTaskTypes.cs
+++ b/src/TelegramPanel.Core/BatchTasks/BatchTaskTypes.cs
@@ -15,6 +15,9 @@ public static class BatchTaskTypes
public const string UserJoinSubscribe = "user_join_subscribe";
public const string UserChatActive = "user_chat_active";
+ // System 任务(记录到任务中心)
+ public const string AccountAutoSync = "account_auto_sync";
+
// External API(记录到任务中心)
public const string ExternalApiKick = "external_api_kick";
}
diff --git a/src/TelegramPanel.Core/Interfaces/IChannelService.cs b/src/TelegramPanel.Core/Interfaces/IChannelService.cs
index f2013ae..62a54d7 100644
--- a/src/TelegramPanel.Core/Interfaces/IChannelService.cs
+++ b/src/TelegramPanel.Core/Interfaces/IChannelService.cs
@@ -17,6 +17,11 @@ public interface IChannelService
///
Task> GetAdminedChannelsAsync(int accountId);
+ ///
+ /// 获取账号当前可见的全部频道(创建者/管理员/普通成员)
+ ///
+ Task> GetVisibleChannelsAsync(int accountId, CancellationToken cancellationToken = default);
+
///
/// 创建新频道
///
@@ -52,6 +57,16 @@ public interface IChannelService
///
Task SetForwardingAllowedAsync(int accountId, long channelId, bool allowed);
+ ///
+ /// 退出频道或取消订阅。
+ ///
+ Task LeaveChannelAsync(int accountId, long channelId);
+
+ ///
+ /// 解散频道(需要频道创建者权限)。
+ ///
+ Task DisbandChannelAsync(int accountId, long channelId);
+
///
/// 导出频道加入链接:公开频道返回 t.me 链接;私密频道导出邀请链接(需要权限)。
///
diff --git a/src/TelegramPanel.Core/Interfaces/IGroupService.cs b/src/TelegramPanel.Core/Interfaces/IGroupService.cs
index 9495c83..c0d939d 100644
--- a/src/TelegramPanel.Core/Interfaces/IGroupService.cs
+++ b/src/TelegramPanel.Core/Interfaces/IGroupService.cs
@@ -12,11 +12,31 @@ public interface IGroupService
///
Task> GetOwnedGroupsAsync(int accountId);
+ ///
+ /// 创建群组(超级群组)
+ ///
+ Task CreateGroupAsync(int accountId, string title, string about, bool isPublic = false, string? username = null);
+
+ ///
+ /// 获取账号当前可见的全部群组(创建者/管理员/普通成员)
+ ///
+ Task> GetVisibleGroupsAsync(int accountId, CancellationToken cancellationToken = default);
+
///
/// 获取群组详情
///
Task GetGroupInfoAsync(int accountId, long groupId);
+ ///
+ /// 退出群组。
+ ///
+ Task LeaveGroupAsync(int accountId, long groupId);
+
+ ///
+ /// 解散群组(超级群需要创建者权限;基础群暂不支持)。
+ ///
+ Task DisbandGroupAsync(int accountId, long groupId);
+
///
/// 导出加入链接:公开群组返回 t.me 链接;否则导出邀请链接。
///
diff --git a/src/TelegramPanel.Core/Models/GroupInfo.cs b/src/TelegramPanel.Core/Models/GroupInfo.cs
index 70008ab..c50f6b6 100644
--- a/src/TelegramPanel.Core/Models/GroupInfo.cs
+++ b/src/TelegramPanel.Core/Models/GroupInfo.cs
@@ -11,7 +11,11 @@ public record GroupInfo
public string Title { get; init; } = string.Empty;
public string? Username { get; init; }
public int MemberCount { get; init; }
- public int CreatorAccountId { get; init; }
+ public string? About { get; init; }
+ public int? CreatorAccountId { get; init; }
+ public bool IsCreator { get; init; }
+ public bool IsAdmin { get; init; }
+ public DateTime? CreatedAt { get; init; }
public DateTime SyncedAt { get; init; }
public bool IsPublic => !string.IsNullOrEmpty(Username);
diff --git a/src/TelegramPanel.Core/ServiceCollectionExtensions.cs b/src/TelegramPanel.Core/ServiceCollectionExtensions.cs
index 00ab607..cf9d40b 100644
--- a/src/TelegramPanel.Core/ServiceCollectionExtensions.cs
+++ b/src/TelegramPanel.Core/ServiceCollectionExtensions.cs
@@ -35,6 +35,7 @@ public static class ServiceCollectionExtensions
services.AddScoped();
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
services.AddScoped();
services.AddScoped();
diff --git a/src/TelegramPanel.Core/Services/BatchTaskManagementService.cs b/src/TelegramPanel.Core/Services/BatchTaskManagementService.cs
index cbcff7e..c1542a2 100644
--- a/src/TelegramPanel.Core/Services/BatchTaskManagementService.cs
+++ b/src/TelegramPanel.Core/Services/BatchTaskManagementService.cs
@@ -1,3 +1,5 @@
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Logging;
using TelegramPanel.Data.Entities;
using TelegramPanel.Data.Repositories;
@@ -9,10 +11,17 @@ namespace TelegramPanel.Core.Services;
public class BatchTaskManagementService
{
private readonly IBatchTaskRepository _batchTaskRepository;
+ private readonly IConfiguration _configuration;
+ private readonly ILogger _logger;
- public BatchTaskManagementService(IBatchTaskRepository batchTaskRepository)
+ public BatchTaskManagementService(
+ IBatchTaskRepository batchTaskRepository,
+ IConfiguration configuration,
+ ILogger logger)
{
_batchTaskRepository = batchTaskRepository;
+ _configuration = configuration;
+ _logger = logger;
}
public async Task GetTaskAsync(int id)
@@ -40,6 +49,11 @@ public class BatchTaskManagementService
return await _batchTaskRepository.GetRecentTasksAsync(count);
}
+ public async Task TrimHistoryTasksAsync(int keepCount, CancellationToken cancellationToken = default)
+ {
+ return await _batchTaskRepository.TrimHistoryTasksAsync(keepCount, cancellationToken);
+ }
+
public async Task CreateTaskAsync(BatchTask task)
{
task.CreatedAt = DateTime.UtcNow;
@@ -123,12 +137,13 @@ public class BatchTaskManagementService
public async Task CompleteTaskAsync(int taskId, bool success = true)
{
var task = await _batchTaskRepository.GetByIdAsync(taskId);
- if (task != null)
- {
- task.Status = success ? "completed" : "failed";
- task.CompletedAt = DateTime.UtcNow;
- await _batchTaskRepository.UpdateAsync(task);
- }
+ if (task == null)
+ return;
+
+ task.Status = success ? "completed" : "failed";
+ task.CompletedAt = DateTime.UtcNow;
+ await _batchTaskRepository.UpdateAsync(task);
+ await TrimHistoryTasksIfNeededAsync();
}
public async Task DeleteTaskAsync(int id)
@@ -139,4 +154,29 @@ public class BatchTaskManagementService
await _batchTaskRepository.DeleteAsync(task);
}
}
+
+ private async Task TrimHistoryTasksIfNeededAsync(CancellationToken cancellationToken = default)
+ {
+ var keepCount = GetHistoryRetentionLimit();
+ if (keepCount <= 0)
+ return;
+
+ var deletedCount = await _batchTaskRepository.TrimHistoryTasksAsync(keepCount, cancellationToken);
+ if (deletedCount > 0)
+ {
+ _logger.LogInformation(
+ "Trimmed {DeletedCount} historical batch tasks, keepCount={KeepCount}",
+ deletedCount,
+ keepCount);
+ }
+ }
+
+ private int GetHistoryRetentionLimit()
+ {
+ var rawValue = _configuration["BatchTasks:HistoryRetentionLimit"];
+ if (!int.TryParse(rawValue, out var keepCount) || keepCount < 0)
+ return 0;
+
+ return Math.Min(keepCount, 5000);
+ }
}
diff --git a/src/TelegramPanel.Core/Services/ChannelManagementService.cs b/src/TelegramPanel.Core/Services/ChannelManagementService.cs
index 4741f47..0d9b149 100644
--- a/src/TelegramPanel.Core/Services/ChannelManagementService.cs
+++ b/src/TelegramPanel.Core/Services/ChannelManagementService.cs
@@ -33,25 +33,19 @@ public class ChannelManagementService
}
///
- /// 用于列表展示:可选按账号筛选(仅展示“本系统创建/创建者为该账号”的频道)
+ /// 用于列表展示:支持按账号查看该账号可见的全部频道,并按角色筛选。
///
- public async Task> GetChannelsForViewAsync(int accountId, bool includeNonCreator)
- {
- if (accountId <= 0)
- return await _channelRepository.GetCreatedAsync();
-
- return await _channelRepository.GetByCreatorAccountAsync(accountId);
- }
-
public async Task<(IReadOnlyList Items, int TotalCount)> QueryChannelsForViewPagedAsync(
- int creatorAccountId,
+ int accountId,
+ int? groupId,
string? filterType,
+ string? membershipRole,
string? search,
int pageIndex,
int pageSize,
CancellationToken cancellationToken = default)
{
- return await _channelRepository.QueryForViewPagedAsync(creatorAccountId, filterType, search, pageIndex, pageSize, cancellationToken);
+ return await _channelRepository.QueryForViewPagedAsync(accountId, groupId, filterType, membershipRole, search, pageIndex, pageSize, cancellationToken);
}
public async Task> GetChannelsByCreatorAsync(int accountId)
@@ -190,7 +184,85 @@ public class ChannelManagementService
public async Task DeleteStaleAccountChannelsAsync(int accountId, IReadOnlyCollection keepChannelIds)
{
- await _accountChannelRepository.DeleteForAccountExceptAsync(accountId, keepChannelIds);
+ var keepSet = keepChannelIds.Count == 0
+ ? new HashSet()
+ : keepChannelIds.ToHashSet();
+
+ var staleChannelIds = (await _accountChannelRepository.GetByAccountAsync(accountId))
+ .Where(x => !keepSet.Contains(x.ChannelId))
+ .Select(x => x.ChannelId)
+ .Distinct()
+ .ToList();
+
+ foreach (var channelId in staleChannelIds)
+ await RemoveAccountChannelAsync(channelId, accountId);
+
+ var staleChannelIdSet = staleChannelIds.ToHashSet();
+ var creatorOnlyChannelIds = (await _channelRepository.FindAsync(x => x.CreatorAccountId == accountId))
+ .Select(x => x.Id)
+ .Where(id => !keepSet.Contains(id) && !staleChannelIdSet.Contains(id))
+ .Distinct()
+ .ToList();
+
+ foreach (var channelId in creatorOnlyChannelIds)
+ await DetachCreatorFromChannelAsync(channelId, accountId);
+
+ await _channelRepository.DeleteOrphanedAsync();
+ }
+
+ public async Task> GetAccountChannelMembershipsAsync(int accountId, CancellationToken cancellationToken = default)
+ {
+ return await _accountChannelRepository.GetByAccountAsync(accountId, cancellationToken);
+ }
+
+ public async Task> GetChannelAccountMembershipsAsync(int channelId, CancellationToken cancellationToken = default)
+ {
+ return await _accountChannelRepository.GetByChannelAsync(channelId, cancellationToken);
+ }
+
+ public async Task RemoveAccountChannelAsync(int channelId, int accountId)
+ {
+ var channel = await _channelRepository.GetByIdAsync(channelId);
+ if (channel == null)
+ return;
+
+ var hasRemainingRelations = channel.AccountChannels.Any(x => x.AccountId != accountId);
+ var creatorRemoved = channel.CreatorAccountId == accountId;
+
+ await _accountChannelRepository.DeleteAsync(accountId, channelId);
+
+ if (creatorRemoved)
+ channel.CreatorAccountId = null;
+
+ if (!hasRemainingRelations && channel.CreatorAccountId == null)
+ {
+ await _channelRepository.DeleteAsync(channel);
+ return;
+ }
+
+ if (!creatorRemoved)
+ return;
+
+ channel.SyncedAt = DateTime.UtcNow;
+ await _channelRepository.UpdateAsync(channel);
+ }
+
+ private async Task DetachCreatorFromChannelAsync(int channelId, int accountId)
+ {
+ var channel = await _channelRepository.GetByIdAsync(channelId);
+ if (channel == null || channel.CreatorAccountId != accountId)
+ return;
+
+ channel.CreatorAccountId = null;
+
+ if (channel.AccountChannels.Count == 0)
+ {
+ await _channelRepository.DeleteAsync(channel);
+ return;
+ }
+
+ channel.SyncedAt = DateTime.UtcNow;
+ await _channelRepository.UpdateAsync(channel);
}
///
diff --git a/src/TelegramPanel.Core/Services/GroupCategoryManagementService.cs b/src/TelegramPanel.Core/Services/GroupCategoryManagementService.cs
new file mode 100644
index 0000000..066809f
--- /dev/null
+++ b/src/TelegramPanel.Core/Services/GroupCategoryManagementService.cs
@@ -0,0 +1,49 @@
+using TelegramPanel.Data.Entities;
+using TelegramPanel.Data.Repositories;
+
+namespace TelegramPanel.Core.Services;
+
+///
+/// 群组分类管理服务
+///
+public class GroupCategoryManagementService
+{
+ private readonly IGroupCategoryRepository _categoryRepository;
+
+ public GroupCategoryManagementService(IGroupCategoryRepository categoryRepository)
+ {
+ _categoryRepository = categoryRepository;
+ }
+
+ public async Task> GetAllCategoriesAsync()
+ {
+ return await _categoryRepository.GetAllAsync();
+ }
+
+ public async Task GetCategoryAsync(int id)
+ {
+ return await _categoryRepository.GetByIdAsync(id);
+ }
+
+ public async Task GetCategoryByNameAsync(string name)
+ {
+ return await _categoryRepository.GetByNameAsync(name);
+ }
+
+ public async Task CreateCategoryAsync(GroupCategory category)
+ {
+ return await _categoryRepository.AddAsync(category);
+ }
+
+ public async Task UpdateCategoryAsync(GroupCategory category)
+ {
+ await _categoryRepository.UpdateAsync(category);
+ }
+
+ public async Task DeleteCategoryAsync(int id)
+ {
+ var category = await _categoryRepository.GetByIdAsync(id);
+ if (category != null)
+ await _categoryRepository.DeleteAsync(category);
+ }
+}
diff --git a/src/TelegramPanel.Core/Services/GroupManagementService.cs b/src/TelegramPanel.Core/Services/GroupManagementService.cs
index 6ef8a44..423fbda 100644
--- a/src/TelegramPanel.Core/Services/GroupManagementService.cs
+++ b/src/TelegramPanel.Core/Services/GroupManagementService.cs
@@ -9,10 +9,12 @@ namespace TelegramPanel.Core.Services;
public class GroupManagementService
{
private readonly IGroupRepository _groupRepository;
+ private readonly IAccountGroupRepository _accountGroupRepository;
- public GroupManagementService(IGroupRepository groupRepository)
+ public GroupManagementService(IGroupRepository groupRepository, IAccountGroupRepository accountGroupRepository)
{
_groupRepository = groupRepository;
+ _accountGroupRepository = accountGroupRepository;
}
public async Task GetGroupAsync(int id)
@@ -30,6 +32,19 @@ public class GroupManagementService
return await _groupRepository.GetAllAsync();
}
+ public async Task<(IReadOnlyList Items, int TotalCount)> QueryGroupsForViewPagedAsync(
+ int accountId,
+ int? categoryId,
+ string? filterType,
+ string? membershipRole,
+ string? search,
+ int pageIndex,
+ int pageSize,
+ CancellationToken cancellationToken = default)
+ {
+ return await _groupRepository.QueryForViewPagedAsync(accountId, categoryId, filterType, membershipRole, search, pageIndex, pageSize, cancellationToken);
+ }
+
public async Task> GetGroupsByCreatorAsync(int accountId)
{
return await _groupRepository.GetByCreatorAccountAsync(accountId);
@@ -37,34 +52,54 @@ public class GroupManagementService
public async Task CreateOrUpdateGroupAsync(Group group)
{
+ if (group.TelegramId <= 0)
+ throw new ArgumentException("TelegramId 必须为正数", nameof(group));
+
var existing = await _groupRepository.GetByTelegramIdAsync(group.TelegramId);
if (existing != null)
{
- // 更新现有群组
existing.Title = group.Title;
existing.Username = group.Username;
existing.MemberCount = group.MemberCount;
existing.About = group.About;
existing.AccessHash = group.AccessHash;
+ if (group.CategoryId.HasValue)
+ existing.CategoryId = group.CategoryId;
+ if (existing.CreatorAccountId == null && group.CreatorAccountId != null)
+ existing.CreatorAccountId = group.CreatorAccountId;
+ if (group.CreatedAt.HasValue)
+ existing.CreatedAt = group.CreatedAt;
existing.SyncedAt = DateTime.UtcNow;
await _groupRepository.UpdateAsync(existing);
return existing;
}
- else
- {
- // 创建新群组
- group.SyncedAt = DateTime.UtcNow;
- return await _groupRepository.AddAsync(group);
- }
+
+ group.SyncedAt = DateTime.UtcNow;
+ return await _groupRepository.AddAsync(group);
+ }
+
+ public async Task UpdateGroupAsync(Group group)
+ {
+ group.SyncedAt = DateTime.UtcNow;
+ await _groupRepository.UpdateAsync(group);
}
public async Task DeleteGroupAsync(int id)
{
var group = await _groupRepository.GetByIdAsync(id);
if (group != null)
- {
await _groupRepository.DeleteAsync(group);
+ }
+
+ public async Task UpdateGroupCategoryAsync(int groupId, int? categoryId)
+ {
+ var group = await _groupRepository.GetByIdAsync(groupId);
+ if (group != null)
+ {
+ group.CategoryId = categoryId;
+ group.SyncedAt = DateTime.UtcNow;
+ await _groupRepository.UpdateAsync(group);
}
}
@@ -77,4 +112,114 @@ public class GroupManagementService
{
return await _groupRepository.CountAsync(g => g.CreatorAccountId == accountId);
}
+
+ public async Task UpsertAccountGroupAsync(int accountId, int groupId, bool isCreator, bool isAdmin, DateTime syncedAtUtc)
+ {
+ await _accountGroupRepository.UpsertAsync(new AccountGroup
+ {
+ AccountId = accountId,
+ GroupId = groupId,
+ IsCreator = isCreator,
+ IsAdmin = isAdmin,
+ SyncedAt = syncedAtUtc
+ });
+ }
+
+ public async Task DeleteStaleAccountGroupsAsync(int accountId, IReadOnlyCollection keepGroupIds)
+ {
+ var keepSet = keepGroupIds.Count == 0
+ ? new HashSet()
+ : keepGroupIds.ToHashSet();
+
+ var staleGroupIds = (await _accountGroupRepository.GetByAccountAsync(accountId))
+ .Where(x => !keepSet.Contains(x.GroupId))
+ .Select(x => x.GroupId)
+ .Distinct()
+ .ToList();
+
+ foreach (var groupId in staleGroupIds)
+ await RemoveAccountGroupAsync(groupId, accountId);
+
+ var staleGroupIdSet = staleGroupIds.ToHashSet();
+ var creatorOnlyGroupIds = (await _groupRepository.FindAsync(x => x.CreatorAccountId == accountId))
+ .Select(x => x.Id)
+ .Where(id => !keepSet.Contains(id) && !staleGroupIdSet.Contains(id))
+ .Distinct()
+ .ToList();
+
+ foreach (var groupId in creatorOnlyGroupIds)
+ await DetachCreatorFromGroupAsync(groupId, accountId);
+
+ await _groupRepository.DeleteOrphanedAsync();
+ }
+
+ public async Task> GetAccountGroupMembershipsAsync(int accountId, CancellationToken cancellationToken = default)
+ {
+ return await _accountGroupRepository.GetByAccountAsync(accountId, cancellationToken);
+ }
+
+ public async Task> GetGroupAccountMembershipsAsync(int groupId, CancellationToken cancellationToken = default)
+ {
+ return await _accountGroupRepository.GetByGroupAsync(groupId, cancellationToken);
+ }
+
+ public async Task RemoveAccountGroupAsync(int groupId, int accountId)
+ {
+ var group = await _groupRepository.GetByIdAsync(groupId);
+ if (group == null)
+ return;
+
+ var hasRemainingRelations = group.AccountGroups.Any(x => x.AccountId != accountId);
+ var creatorRemoved = group.CreatorAccountId == accountId;
+
+ await _accountGroupRepository.DeleteAsync(accountId, groupId);
+
+ if (creatorRemoved)
+ group.CreatorAccountId = null;
+
+ if (!hasRemainingRelations && group.CreatorAccountId == null)
+ {
+ await _groupRepository.DeleteAsync(group);
+ return;
+ }
+
+ if (!creatorRemoved)
+ return;
+
+ group.SyncedAt = DateTime.UtcNow;
+ await _groupRepository.UpdateAsync(group);
+ }
+
+ private async Task DetachCreatorFromGroupAsync(int groupId, int accountId)
+ {
+ var group = await _groupRepository.GetByIdAsync(groupId);
+ if (group == null || group.CreatorAccountId != accountId)
+ return;
+
+ group.CreatorAccountId = null;
+
+ if (group.AccountGroups.Count == 0)
+ {
+ await _groupRepository.DeleteAsync(group);
+ return;
+ }
+
+ group.SyncedAt = DateTime.UtcNow;
+ await _groupRepository.UpdateAsync(group);
+ }
+
+ ///
+ /// 解析群组操作的执行账号:
+ /// 优先使用 preferredAccountId,其次 CreatorAccountId,否则从关联表中挑选一个管理员账号。
+ ///
+ public async Task ResolveExecuteAccountIdAsync(Group group, int? preferredAccountId = null)
+ {
+ if (preferredAccountId.HasValue && preferredAccountId.Value > 0)
+ return preferredAccountId.Value;
+
+ if (group.CreatorAccountId.HasValue)
+ return group.CreatorAccountId.Value;
+
+ 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 05a0445..8d2e55e 100644
--- a/src/TelegramPanel.Core/Services/Telegram/AccountTelegramToolsService.cs
+++ b/src/TelegramPanel.Core/Services/Telegram/AccountTelegramToolsService.cs
@@ -50,7 +50,12 @@ public class AccountTelegramToolsService
var client = await GetOrCreateConnectedClientAsync(accountId, cancellationToken);
cancellationToken.ThrowIfCancellationRequested();
- var users = await client.Users_GetUsers(InputUser.Self);
+ var users = await ExecuteTelegramRequestAsync(
+ accountId,
+ "拉取账号资料",
+ () => client.Users_GetUsers(InputUser.Self),
+ cancellationToken,
+ resetClientOnTimeout: true);
cancellationToken.ThrowIfCancellationRequested();
var self = users.OfType().FirstOrDefault();
@@ -83,6 +88,7 @@ public class AccountTelegramToolsService
if (account != null)
{
profile.ApplyTo(account);
+ await TryPopulateEstimatedRegistrationAsync(account, client, accountId, cancellationToken);
}
var summary = "正常";
@@ -241,6 +247,127 @@ public class AccountTelegramToolsService
.ToList();
}
+ public async Task EnsureEstimatedRegistrationAsync(int accountId, CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ var account = await _accountManagement.GetAccountAsync(accountId);
+ if (account == null)
+ return;
+
+ if (account.EstimatedRegistrationAt.HasValue || account.EstimatedRegistrationCheckedAtUtc.HasValue)
+ return;
+
+ var client = await GetOrCreateConnectedClientAsync(accountId, cancellationToken);
+ await TryPopulateEstimatedRegistrationAsync(account, client, accountId, cancellationToken);
+
+ if (account.EstimatedRegistrationAt.HasValue || account.EstimatedRegistrationCheckedAtUtc.HasValue)
+ await _accountManagement.UpdateAccountAsync(account);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogDebug(ex, "EnsureEstimatedRegistrationAsync skipped for account {AccountId}", accountId);
+ }
+ }
+
+ private async Task TryPopulateEstimatedRegistrationAsync(
+ Account account,
+ Client client,
+ int accountId,
+ CancellationToken cancellationToken)
+ {
+ if (account.EstimatedRegistrationAt.HasValue || account.EstimatedRegistrationCheckedAtUtc.HasValue)
+ return;
+
+ var (checkedSuccessfully, estimatedAtUtc) = await TryGetEstimatedRegistrationFromSystemMessagesAsync(client, accountId, cancellationToken);
+ if (!checkedSuccessfully)
+ return;
+
+ if (estimatedAtUtc.HasValue)
+ account.EstimatedRegistrationAt = estimatedAtUtc.Value;
+
+ account.EstimatedRegistrationCheckedAtUtc = DateTime.UtcNow;
+ }
+
+ private async Task<(bool CheckedSuccessfully, DateTime? EstimatedAtUtc)> TryGetEstimatedRegistrationFromSystemMessagesAsync(
+ Client client,
+ int accountId,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ var peer = await TryResolveSystemPeerAsync(client);
+ if (peer == null)
+ return (true, null);
+
+ const int pageSize = 100;
+ const int maxPages = 200;
+ var offsetId = 0;
+ DateTime? earliest = null;
+
+ for (var page = 0; page < maxPages; page++)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var history = await ExecuteTelegramRequestAsync(
+ accountId,
+ "读取 777000 系统通知历史",
+ () => client.Messages_GetHistory(peer, offset_id: offsetId, limit: pageSize),
+ cancellationToken,
+ resetClientOnTimeout: true);
+
+ if (history.Messages == null || history.Messages.Length == 0)
+ break;
+
+ foreach (var msgBase in history.Messages)
+ {
+ if (msgBase is not Message message)
+ continue;
+
+ if (string.IsNullOrWhiteSpace(message.message))
+ continue;
+
+ var messageUtc = message.Date.ToUniversalTime();
+ if (!earliest.HasValue || messageUtc < earliest.Value)
+ earliest = messageUtc;
+ }
+
+ var nextOffsetId = history.Messages
+ .Select(GetTelegramMessageId)
+ .Where(id => id > 0)
+ .DefaultIfEmpty(0)
+ .Min();
+
+ if (nextOffsetId <= 0 || nextOffsetId == offsetId || history.Messages.Length < pageSize)
+ break;
+
+ offsetId = nextOffsetId;
+ }
+
+ return (true, earliest);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogDebug(ex, "Failed to estimate registration time from 777000 for account {AccountId}", accountId);
+ return (false, null);
+ }
+ }
+
+ private static int GetTelegramMessageId(MessageBase msgBase) => msgBase switch
+ {
+ Message message => message.id,
+ MessageService service => service.id,
+ _ => 0
+ };
+
public async Task> GetAuthorizationsAsync(int accountId)
{
var client = await GetOrCreateConnectedClientAsync(accountId);
@@ -1409,10 +1536,22 @@ public class AccountTelegramToolsService
try
{
- await client.ConnectAsync();
+ await ExecuteTelegramRequestAsync(
+ accountId,
+ "连接 Telegram",
+ () => client.ConnectAsync(),
+ cancellationToken,
+ resetClientOnTimeout: true);
cancellationToken.ThrowIfCancellationRequested();
if (client.User == null && (client.UserId != 0 || account.UserId != 0))
- await client.LoginUserIfNeeded(reloginOnFailedResume: false);
+ {
+ await ExecuteTelegramRequestAsync(
+ accountId,
+ "恢复 Telegram 登录状态",
+ () => client.LoginUserIfNeeded(reloginOnFailedResume: false),
+ cancellationToken,
+ resetClientOnTimeout: true);
+ }
}
catch (Exception ex)
{
@@ -1433,6 +1572,70 @@ public class AccountTelegramToolsService
return client;
}
+ private TimeSpan GetTelegramRequestTimeout()
+ {
+ var seconds = int.TryParse(_configuration["Telegram:RequestTimeoutSeconds"], out var parsedSeconds)
+ ? parsedSeconds
+ : 90;
+ return TimeSpan.FromSeconds(Math.Clamp(seconds, 15, 600));
+ }
+
+ private async Task ExecuteTelegramRequestAsync(
+ int accountId,
+ string operation,
+ Func action,
+ CancellationToken cancellationToken,
+ bool resetClientOnTimeout)
+ {
+ var timeout = GetTelegramRequestTimeout();
+
+ try
+ {
+ await action().WaitAsync(timeout, cancellationToken);
+ }
+ catch (TimeoutException)
+ {
+ _logger.LogWarning(
+ "Telegram request timed out after {TimeoutSeconds}s for account {AccountId}: {Operation}",
+ timeout.TotalSeconds,
+ accountId,
+ operation);
+
+ if (resetClientOnTimeout)
+ await _clientPool.RemoveClientAsync(accountId);
+
+ throw new TimeoutException($"Telegram 请求超时:{operation} 超过 {timeout.TotalSeconds:0} 秒,可能是 Session 失效、账号受限、网络异常或代理异常");
+ }
+ }
+
+ private async Task ExecuteTelegramRequestAsync(
+ int accountId,
+ string operation,
+ Func> action,
+ CancellationToken cancellationToken,
+ bool resetClientOnTimeout)
+ {
+ var timeout = GetTelegramRequestTimeout();
+
+ try
+ {
+ return await action().WaitAsync(timeout, cancellationToken);
+ }
+ catch (TimeoutException)
+ {
+ _logger.LogWarning(
+ "Telegram request timed out after {TimeoutSeconds}s for account {AccountId}: {Operation}",
+ timeout.TotalSeconds,
+ accountId,
+ operation);
+
+ if (resetClientOnTimeout)
+ await _clientPool.RemoveClientAsync(accountId);
+
+ throw new TimeoutException($"Telegram 请求超时:{operation} 超过 {timeout.TotalSeconds:0} 秒,可能是 Session 失效、账号受限、网络异常或代理异常");
+ }
+ }
+
private int ResolveApiId(Account account)
{
if (int.TryParse(_configuration["Telegram:ApiId"], out var globalApiId) && globalApiId > 0)
@@ -1492,7 +1695,12 @@ public class AccountTelegramToolsService
UpdatesBase updates;
try
{
- updates = await client.Channels_CreateChannel(title: title, about: about, broadcast: true);
+ updates = await ExecuteTelegramRequestAsync(
+ accountId,
+ "创建测试频道探测账号状态",
+ () => client.Channels_CreateChannel(title: title, about: about, broadcast: true),
+ cancellationToken,
+ resetClientOnTimeout: true);
}
catch (RpcException ex) when (ex.Code == 420 && string.Equals(ex.Message, "FROZEN_METHOD_INVALID", StringComparison.OrdinalIgnoreCase))
{
@@ -1509,7 +1717,12 @@ public class AccountTelegramToolsService
{
// 立即删除,避免留下垃圾频道
var input = new InputChannel(channel.id, channel.access_hash);
- await client.Channels_DeleteChannel(input);
+ await ExecuteTelegramRequestAsync(
+ accountId,
+ $"删除测试频道({channel.id})",
+ () => client.Channels_DeleteChannel(input),
+ cancellationToken,
+ resetClientOnTimeout: false);
}
catch (Exception ex)
{
@@ -1535,6 +1748,12 @@ public class AccountTelegramToolsService
{
var msg = ex.Message ?? string.Empty;
+ if (ex is TimeoutException
+ || msg.Contains("请求超时", StringComparison.OrdinalIgnoreCase)
+ || msg.Contains("timed out", StringComparison.OrdinalIgnoreCase)
+ || msg.Contains("timeout", StringComparison.OrdinalIgnoreCase))
+ return ("请求超时", msg);
+
if (msg.Contains("EMAIL_HASH_EXPIRED", StringComparison.OrdinalIgnoreCase))
return (
"邮箱验证码已过期(EMAIL_HASH_EXPIRED)",
diff --git a/src/TelegramPanel.Core/Services/Telegram/ChannelService.cs b/src/TelegramPanel.Core/Services/Telegram/ChannelService.cs
index 10d4925..905b93d 100644
--- a/src/TelegramPanel.Core/Services/Telegram/ChannelService.cs
+++ b/src/TelegramPanel.Core/Services/Telegram/ChannelService.cs
@@ -39,7 +39,7 @@ public class ChannelService : IChannelService
foreach (var (_, chat) in dialogs.chats)
{
- if (chat is not Channel channel || !channel.IsActive)
+ if (chat is not Channel channel || !channel.IsActive || !channel.IsChannel)
continue;
try
@@ -98,7 +98,7 @@ public class ChannelService : IChannelService
foreach (var (_, chat) in dialogs.chats)
{
- if (chat is not Channel channel || !channel.IsActive)
+ if (chat is not Channel channel || !channel.IsActive || !channel.IsChannel)
continue;
try
@@ -152,6 +152,79 @@ public class ChannelService : IChannelService
return channels;
}
+ public async Task> GetVisibleChannelsAsync(int accountId, CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var client = await GetOrCreateConnectedClientAsync(accountId, cancellationToken);
+
+ var channels = new List();
+ var dialogs = await ExecuteTelegramRequestAsync(
+ accountId,
+ "拉取频道/群组对话列表",
+ () => client.Messages_GetAllDialogs(),
+ cancellationToken,
+ resetClientOnTimeout: true);
+
+ foreach (var (_, chat) in dialogs.chats)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ if (chat is not Channel channel || !channel.IsActive || !channel.IsChannel)
+ continue;
+
+ try
+ {
+ var isCreator =
+ ReadBool(channel, "creator", "Creator", "is_creator", "IsCreator")
+ || ReadFlagsHas(channel, flagName: "creator", memberNames: new[] { "flags", "Flags" });
+ var adminRights = ReadObject(channel, "admin_rights", "AdminRights", "adminRights");
+ var isAdmin = isCreator || adminRights != null;
+
+ var memberCount = ReadInt(channel, 0, "participants_count", "ParticipantsCount", "participantsCount", "memberCount", "MemberCount");
+ string? about = null;
+ try
+ {
+ var fullChannel = await ExecuteTelegramRequestAsync(
+ accountId,
+ $"拉取频道详情({channel.id})",
+ () => client.Channels_GetFullChannel(channel),
+ cancellationToken,
+ resetClientOnTimeout: false);
+ memberCount = fullChannel.full_chat.ParticipantsCount;
+ about = (fullChannel.full_chat as ChannelFull)?.about;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogDebug(ex, "Failed to get full visible channel info for {ChannelId}", channel.id);
+ }
+
+ channels.Add(new ChannelInfo
+ {
+ TelegramId = channel.id,
+ AccessHash = channel.access_hash,
+ Title = channel.title,
+ Username = channel.MainUsername,
+ IsBroadcast = channel.IsChannel,
+ MemberCount = memberCount,
+ About = about,
+ CreatorAccountId = isCreator ? accountId : null,
+ IsCreator = isCreator,
+ IsAdmin = isAdmin,
+ CreatedAt = channel.date,
+ SyncedAt = DateTime.UtcNow
+ });
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed to get visible channel info for {ChannelId}", channel.id);
+ }
+ }
+
+ _logger.LogInformation("Found {Count} visible channels for account {AccountId}", channels.Count, accountId);
+ return channels;
+ }
+
private static bool ReadBool(object obj, params string[] names)
{
foreach (var name in names)
@@ -666,6 +739,46 @@ public class ChannelService : IChannelService
return true;
}
+ public async Task LeaveChannelAsync(int accountId, long channelId)
+ {
+ try
+ {
+ var client = await GetOrCreateConnectedClientAsync(accountId);
+ var channel = await GetChannelByIdAsync(client, channelId)
+ ?? throw new InvalidOperationException($"频道 {channelId} not found");
+
+ await ExecuteTelegramRequestAsync(
+ accountId,
+ $"退出频道({channel.id})",
+ () => client.LeaveChat(channel.ToInputPeer()),
+ CancellationToken.None,
+ resetClientOnTimeout: false);
+
+ return true;
+ }
+ catch (RpcException ex) when (ex.Code == 400 && string.Equals(ex.Message, "USER_NOT_PARTICIPANT", StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+ }
+
+ public async Task DisbandChannelAsync(int accountId, long channelId)
+ {
+ var client = await GetOrCreateConnectedClientAsync(accountId);
+ var channel = await GetChannelByIdAsync(client, channelId)
+ ?? throw new InvalidOperationException($"频道 {channelId} not found");
+
+ var input = new InputChannel(channel.id, channel.access_hash);
+ await ExecuteTelegramRequestAsync(
+ accountId,
+ $"解散频道({channel.id})",
+ () => client.Channels_DeleteChannel(input),
+ CancellationToken.None,
+ resetClientOnTimeout: false);
+
+ return true;
+ }
+
public async Task ExportJoinLinkAsync(int accountId, long channelId)
{
var client = await GetOrCreateConnectedClientAsync(accountId);
@@ -832,8 +945,10 @@ public class ChannelService : IChannelService
.FirstOrDefault(c => candidateSet?.Contains(c.id) ?? c.id == channelId);
}
- private async Task GetOrCreateConnectedClientAsync(int accountId)
+ private async Task GetOrCreateConnectedClientAsync(int accountId, CancellationToken cancellationToken = default)
{
+ cancellationToken.ThrowIfCancellationRequested();
+
var existing = _clientPool.GetClient(accountId);
if (existing?.User != null)
return existing;
@@ -873,9 +988,22 @@ public class ChannelService : IChannelService
try
{
- await client.ConnectAsync();
+ await ExecuteTelegramRequestAsync(
+ accountId,
+ "连接 Telegram",
+ () => client.ConnectAsync(),
+ cancellationToken,
+ resetClientOnTimeout: true);
+
if (client.User == null && (client.UserId != 0 || account.UserId != 0))
- await client.LoginUserIfNeeded(reloginOnFailedResume: false);
+ {
+ await ExecuteTelegramRequestAsync(
+ accountId,
+ "恢复 Telegram 登录状态",
+ () => client.LoginUserIfNeeded(reloginOnFailedResume: false),
+ cancellationToken,
+ resetClientOnTimeout: true);
+ }
}
catch (Exception ex)
{
@@ -896,6 +1024,70 @@ public class ChannelService : IChannelService
return client;
}
+ private TimeSpan GetTelegramRequestTimeout()
+ {
+ var seconds = int.TryParse(_configuration["Telegram:RequestTimeoutSeconds"], out var parsedSeconds)
+ ? parsedSeconds
+ : 90;
+ return TimeSpan.FromSeconds(Math.Clamp(seconds, 15, 600));
+ }
+
+ private async Task ExecuteTelegramRequestAsync(
+ int accountId,
+ string operation,
+ Func action,
+ CancellationToken cancellationToken,
+ bool resetClientOnTimeout)
+ {
+ var timeout = GetTelegramRequestTimeout();
+
+ try
+ {
+ await action().WaitAsync(timeout, cancellationToken);
+ }
+ catch (TimeoutException)
+ {
+ _logger.LogWarning(
+ "Telegram request timed out after {TimeoutSeconds}s for account {AccountId}: {Operation}",
+ timeout.TotalSeconds,
+ accountId,
+ operation);
+
+ if (resetClientOnTimeout)
+ await _clientPool.RemoveClientAsync(accountId);
+
+ throw new TimeoutException($"Telegram 请求超时:{operation} 超过 {timeout.TotalSeconds:0} 秒,可能是 Session 失效、账号受限、网络异常或代理异常");
+ }
+ }
+
+ private async Task ExecuteTelegramRequestAsync(
+ int accountId,
+ string operation,
+ Func> action,
+ CancellationToken cancellationToken,
+ bool resetClientOnTimeout)
+ {
+ var timeout = GetTelegramRequestTimeout();
+
+ try
+ {
+ return await action().WaitAsync(timeout, cancellationToken);
+ }
+ catch (TimeoutException)
+ {
+ _logger.LogWarning(
+ "Telegram request timed out after {TimeoutSeconds}s for account {AccountId}: {Operation}",
+ timeout.TotalSeconds,
+ accountId,
+ operation);
+
+ if (resetClientOnTimeout)
+ await _clientPool.RemoveClientAsync(accountId);
+
+ throw new TimeoutException($"Telegram 请求超时:{operation} 超过 {timeout.TotalSeconds:0} 秒,可能是 Session 失效、账号受限、网络异常或代理异常");
+ }
+ }
+
private int ResolveApiId(TelegramPanel.Data.Entities.Account account)
{
if (int.TryParse(_configuration["Telegram:ApiId"], out var globalApiId) && globalApiId > 0)
diff --git a/src/TelegramPanel.Core/Services/Telegram/GroupService.cs b/src/TelegramPanel.Core/Services/Telegram/GroupService.cs
index 87b221a..dd2743a 100644
--- a/src/TelegramPanel.Core/Services/Telegram/GroupService.cs
+++ b/src/TelegramPanel.Core/Services/Telegram/GroupService.cs
@@ -1,6 +1,6 @@
using System.Linq;
-using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Logging;
using TelegramPanel.Core.Interfaces;
using TelegramPanel.Core.Models;
using TelegramPanel.Core.Services;
@@ -31,37 +31,124 @@ public class GroupService : IGroupService
}
public async Task> GetOwnedGroupsAsync(int accountId)
+ {
+ var groups = await GetVisibleGroupsAsync(accountId);
+ return groups.Where(x => x.IsCreator).ToList();
+ }
+
+ public async Task CreateGroupAsync(int accountId, string title, string about, bool isPublic = false, string? username = null)
{
var client = await GetOrCreateConnectedClientAsync(accountId);
- var ownedGroups = new List();
- var dialogs = await client.Messages_GetAllDialogs();
+ _logger.LogInformation("Creating group '{Title}' for account {AccountId}", title, accountId);
- foreach (var (id, chat) in dialogs.chats)
+ if (isPublic)
{
- // 处理基础群组(Chat类型,非Channel)
- // 注意:基础 Chat 类型无法直接判断创建者,需要通过 GetFullChat 获取
+ username = username?.Trim().TrimStart('@');
+ if (string.IsNullOrWhiteSpace(username))
+ throw new InvalidOperationException("公开群组需要设置用户名");
+ }
+
+ UpdatesBase updates;
+ try
+ {
+ updates = await client.Channels_CreateChannel(
+ title: title,
+ about: about,
+ broadcast: false,
+ megagroup: true
+ );
+ }
+ catch (RpcException ex) when (ex.Code == 420 && string.Equals(ex.Message, "FROZEN_METHOD_INVALID", StringComparison.OrdinalIgnoreCase))
+ {
+ throw new InvalidOperationException(
+ "Telegram 返回 FROZEN_METHOD_INVALID:当前 ApiId/ApiHash 或账号被 Telegram 限制调用创建群组接口。" +
+ "建议:在【系统设置】更换全局 ApiId/ApiHash(推荐使用你自己在 my.telegram.org 申请的)," +
+ "并重新导入/重新登录生成 session 后再试。",
+ ex);
+ }
+
+ var group = updates.Chats.Values.OfType().FirstOrDefault(c => !c.IsChannel)
+ ?? throw new InvalidOperationException("群组创建失败");
+
+ if (isPublic && !string.IsNullOrWhiteSpace(username))
+ {
+ 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 new GroupInfo
+ {
+ TelegramId = group.id,
+ AccessHash = group.access_hash,
+ Title = group.title,
+ Username = isPublic ? username : group.MainUsername,
+ MemberCount = 0,
+ About = about,
+ CreatorAccountId = accountId,
+ IsCreator = true,
+ IsAdmin = true,
+ CreatedAt = group.date,
+ SyncedAt = DateTime.UtcNow
+ };
+ }
+
+ public async Task> GetVisibleGroupsAsync(int accountId, CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var client = await GetOrCreateConnectedClientAsync(accountId, cancellationToken);
+
+ var groups = new List();
+ var dialogs = await ExecuteTelegramRequestAsync(
+ accountId,
+ "拉取频道/群组对话列表",
+ () => client.Messages_GetAllDialogs(),
+ cancellationToken,
+ resetClientOnTimeout: true);
+
+ foreach (var (_, chat) in dialogs.chats)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ // 基础群组(Chat 类型,非 Channel)
if (chat is Chat basicChat && basicChat.IsActive)
{
+ var memberCount = (int)basicChat.participants_count;
+ var about = ReadString(basicChat, null, "about", "About");
+ var username = ReadString(basicChat, null, "username", "Username", "MainUsername");
+ var isCreator = false;
+ var isAdmin = false;
+
try
{
- // 获取完整信息来判断是否为创建者
- var fullChat = await client.Messages_GetFullChat(basicChat.id);
- if (fullChat.full_chat is ChatFull cf && cf.participants is ChatParticipants cp)
+ var fullChat = await ExecuteTelegramRequestAsync(
+ accountId,
+ $"拉取基础群详情({basicChat.id})",
+ () => client.Messages_GetFullChat(basicChat.id),
+ cancellationToken,
+ resetClientOnTimeout: false);
+ if (fullChat.full_chat is ChatFull cf)
{
- // 检查当前用户是否为创建者
- var creator = cp.participants.OfType()
- .FirstOrDefault(p => p.user_id == client.User!.id);
- if (creator != null)
+ about = ReadString(cf, about, "about", "About");
+ memberCount = Math.Max(memberCount, ReadInt(cf, memberCount, "participants_count", "ParticipantsCount", "participantsCount"));
+
+ if (cf.participants is ChatParticipants cp)
{
- ownedGroups.Add(new GroupInfo
- {
- TelegramId = basicChat.id,
- Title = basicChat.title,
- MemberCount = basicChat.participants_count,
- CreatorAccountId = accountId,
- SyncedAt = DateTime.UtcNow
- });
+ memberCount = Math.Max(memberCount, cp.participants.Count());
+ var self = cp.participants.FirstOrDefault(p => GetChatParticipantUserId(p) == client.User!.id);
+ isCreator = self is ChatParticipantCreator;
+ isAdmin = isCreator || self is ChatParticipantAdmin;
}
}
}
@@ -78,38 +165,53 @@ public class GroupService : IGroupService
_logger.LogDebug(ex, "GetFullChat debug details (chatId={ChatId})", basicChat.id);
}
}
+
+ groups.Add(new GroupInfo
+ {
+ TelegramId = basicChat.id,
+ Title = basicChat.title,
+ Username = username,
+ MemberCount = memberCount,
+ About = about,
+ CreatorAccountId = isCreator ? accountId : null,
+ IsCreator = isCreator,
+ IsAdmin = isAdmin,
+ SyncedAt = DateTime.UtcNow
+ });
+
+ continue;
}
- // 处理超级群组(Channel类型但megagroup=true, 即 !IsChannel)
- else if (chat is Channel channel && channel.IsActive && !channel.IsChannel)
+
+ // 超级群组(Channel 类型但 megagroup=true,即 !IsChannel)
+ if (chat is not Channel channel || !channel.IsActive || channel.IsChannel)
+ continue;
+
+ try
{
+ var isCreator =
+ ReadBool(channel, "creator", "Creator", "is_creator", "IsCreator")
+ || ReadFlagsHas(channel, flagName: "creator", memberNames: new[] { "flags", "Flags" });
+ var adminRights = ReadObject(channel, "admin_rights", "AdminRights", "adminRights");
+ var isAdmin = isCreator || adminRights != null;
+
+ var memberCount = ReadInt(channel, 0, "participants_count", "ParticipantsCount", "participantsCount", "memberCount", "MemberCount");
+ string? about = null;
try
{
- // 通过获取管理员列表来检查当前用户是否为创建者
- var participants = await client.Channels_GetParticipants(channel, new ChannelParticipantsAdmins());
- var isCreator = participants.participants
- .OfType()
- .Any(p => p.user_id == client.User!.id);
-
- if (!isCreator) continue;
-
- var fullChannel = await client.Channels_GetFullChannel(channel);
- ownedGroups.Add(new GroupInfo
- {
- TelegramId = channel.id,
- AccessHash = channel.access_hash,
- Title = channel.title,
- Username = channel.MainUsername,
- MemberCount = fullChannel.full_chat.ParticipantsCount,
- CreatorAccountId = accountId,
- SyncedAt = DateTime.UtcNow
- });
+ var fullChannel = await ExecuteTelegramRequestAsync(
+ accountId,
+ $"拉取超级群详情({channel.id})",
+ () => client.Channels_GetFullChannel(channel),
+ cancellationToken,
+ resetClientOnTimeout: false);
+ memberCount = fullChannel.full_chat.ParticipantsCount;
+ about = (fullChannel.full_chat as ChannelFull)?.about;
}
catch (Exception ex)
{
if (ex.Message.Contains("CHANNEL_MONOFORUM_UNSUPPORTED", StringComparison.OrdinalIgnoreCase))
{
- // 该错误通常不影响账号本身,仅表示 Telegram 不支持对这类“论坛/话题”群组调用某些接口
- _logger.LogDebug("Skipping group {GroupId}: {Error}", channel.id, ex.Message);
+ _logger.LogDebug("Skipping full info for group {GroupId}: {Error}", channel.id, ex.Message);
}
else if (TryGetFloodWaitSeconds(ex.Message, out var seconds))
{
@@ -122,11 +224,30 @@ public class GroupService : IGroupService
_logger.LogDebug(ex, "GetFullGroup debug details (groupId={GroupId})", channel.id);
}
}
+
+ groups.Add(new GroupInfo
+ {
+ TelegramId = channel.id,
+ AccessHash = channel.access_hash,
+ Title = channel.title,
+ Username = channel.MainUsername,
+ MemberCount = memberCount,
+ About = about,
+ CreatorAccountId = isCreator ? accountId : null,
+ IsCreator = isCreator,
+ IsAdmin = isAdmin,
+ CreatedAt = channel.date,
+ SyncedAt = DateTime.UtcNow
+ });
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed to get visible group info for {GroupId}", channel.id);
}
}
- _logger.LogInformation("Found {Count} owned groups for account {AccountId}", ownedGroups.Count, accountId);
- return ownedGroups;
+ _logger.LogInformation("Found {Count} visible groups for account {AccountId}", groups.Count, accountId);
+ return groups;
}
private static bool TryGetFloodWaitSeconds(string message, out int seconds)
@@ -135,7 +256,6 @@ public class GroupService : IGroupService
if (string.IsNullOrWhiteSpace(message))
return false;
- // 典型:FLOOD_WAIT_13
var idx = message.IndexOf("FLOOD_WAIT_", StringComparison.OrdinalIgnoreCase);
if (idx < 0)
return false;
@@ -150,18 +270,235 @@ public class GroupService : IGroupService
return true;
}
+ private static bool ReadBool(object obj, params string[] names)
+ {
+ foreach (var name in names)
+ {
+ if (TryReadMember(obj, name, out var value) && value is bool b)
+ return b;
+ }
+
+ return false;
+ }
+
+ private static bool ReadFlagsHas(object obj, string flagName, string[] memberNames)
+ {
+ foreach (var memberName in memberNames)
+ {
+ if (!TryReadMember(obj, memberName, out var value) || value == null)
+ continue;
+
+ if (value is Enum enumValue)
+ {
+ var flagEnum = TryGetEnumFlag(enumValue.GetType(), flagName);
+ if (flagEnum != null)
+ return enumValue.HasFlag(flagEnum);
+ continue;
+ }
+
+ if (value is int intFlags)
+ return ReadNumericFlagsHas(obj, flagName, intFlags);
+ if (value is long longFlags)
+ return ReadNumericFlagsHas(obj, flagName, longFlags);
+ }
+
+ return false;
+ }
+
+ private static bool ReadNumericFlagsHas(object obj, string flagName, long flagsValue)
+ {
+ var flagsType = obj.GetType().GetNestedType("Flags");
+ if (flagsType == null || !flagsType.IsEnum)
+ return false;
+
+ var flagEnum = TryGetEnumFlag(flagsType, flagName);
+ if (flagEnum == null)
+ return false;
+
+ var flagValue = Convert.ToInt64(flagEnum);
+ return (flagsValue & flagValue) == flagValue;
+ }
+
+ private static Enum? TryGetEnumFlag(Type enumType, string flagName)
+ {
+ var names = Enum.GetNames(enumType);
+ var match = names.FirstOrDefault(n => string.Equals(n, flagName, StringComparison.OrdinalIgnoreCase));
+ if (match == null)
+ return null;
+
+ return (Enum)Enum.Parse(enumType, match);
+ }
+
+ private static int ReadInt(object obj, int fallback, params string[] names)
+ {
+ foreach (var name in names)
+ {
+ if (TryReadMember(obj, name, out var value))
+ {
+ if (value is int i) return i;
+ if (value is long l) return unchecked((int)l);
+ if (value is short s) return s;
+ if (value is byte by) return by;
+ }
+ }
+
+ return fallback;
+ }
+
+ private static long ReadLong(object obj, long fallback, params string[] names)
+ {
+ foreach (var name in names)
+ {
+ if (TryReadMember(obj, name, out var value))
+ {
+ if (value is long l) return l;
+ if (value is int i) return i;
+ if (value is short s) return s;
+ if (value is byte by) return by;
+ }
+ }
+
+ return fallback;
+ }
+
+ private static string? ReadString(object obj, string? fallback, params string[] names)
+ {
+ foreach (var name in names)
+ {
+ if (TryReadMember(obj, name, out var value) && value is string s)
+ return s;
+ }
+
+ return fallback;
+ }
+
+ private static object? ReadObject(object obj, params string[] names)
+ {
+ foreach (var name in names)
+ {
+ if (TryReadMember(obj, name, out var value) && value != null)
+ return value;
+ }
+
+ return null;
+ }
+
+ private static bool TryReadMember(object obj, string name, out object? value)
+ {
+ var type = obj.GetType();
+
+ var prop = type.GetProperty(name);
+ if (prop != null && prop.CanRead)
+ {
+ value = prop.GetValue(obj);
+ return true;
+ }
+
+ var field = type.GetField(name);
+ if (field != null)
+ {
+ value = field.GetValue(obj);
+ return true;
+ }
+
+ value = null;
+ return false;
+ }
+
+ private static long GetChatParticipantUserId(object participant)
+ {
+ return ReadLong(participant, 0, "user_id", "UserId", "userId");
+ }
+
public async Task GetGroupInfoAsync(int accountId, long groupId)
{
- var groups = await GetOwnedGroupsAsync(accountId);
+ var groups = await GetVisibleGroupsAsync(accountId);
return groups.FirstOrDefault(g => g.TelegramId == groupId);
}
+ public async Task LeaveGroupAsync(int accountId, long groupId)
+ {
+ try
+ {
+ var client = await GetOrCreateConnectedClientAsync(accountId);
+ var dialogs = await ExecuteTelegramRequestAsync(
+ accountId,
+ "拉取频道/群组对话列表",
+ () => client.Messages_GetAllDialogs(),
+ CancellationToken.None,
+ resetClientOnTimeout: true);
+
+ var normalizedId = groupId > 0 ? groupId : Math.Abs(groupId);
+ var chat = dialogs.chats.Values.FirstOrDefault(x =>
+ x switch
+ {
+ Chat basicChat => basicChat.id == normalizedId,
+ Channel channel => !channel.IsChannel && channel.id == normalizedId,
+ _ => false
+ });
+
+ if (chat == null)
+ throw new InvalidOperationException($"群组 {groupId} not found");
+
+ await ExecuteTelegramRequestAsync(
+ accountId,
+ $"退出群组({normalizedId})",
+ () => client.LeaveChat(chat.ToInputPeer()),
+ CancellationToken.None,
+ resetClientOnTimeout: false);
+
+ return true;
+ }
+ catch (RpcException ex) when (ex.Code == 400 && string.Equals(ex.Message, "USER_NOT_PARTICIPANT", StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+ }
+
+ public async Task DisbandGroupAsync(int accountId, long groupId)
+ {
+ var client = await GetOrCreateConnectedClientAsync(accountId);
+ var dialogs = await ExecuteTelegramRequestAsync(
+ accountId,
+ "拉取频道/群组对话列表",
+ () => client.Messages_GetAllDialogs(),
+ CancellationToken.None,
+ resetClientOnTimeout: true);
+
+ var normalizedId = groupId > 0 ? groupId : Math.Abs(groupId);
+ var chat = dialogs.chats.Values.FirstOrDefault(x =>
+ x switch
+ {
+ Chat basicChat => basicChat.id == normalizedId,
+ Channel channel => !channel.IsChannel && channel.id == normalizedId,
+ _ => false
+ });
+
+ if (chat == null)
+ throw new InvalidOperationException($"群组 {groupId} not found");
+
+ if (chat is Chat)
+ throw new InvalidOperationException("基础群暂不支持一键解散,请在 Telegram 客户端手动操作或先升级为超级群");
+
+ if (chat is not Channel megaGroup)
+ throw new InvalidOperationException("仅支持解散超级群");
+
+ var input = new InputChannel(megaGroup.id, megaGroup.access_hash);
+ await ExecuteTelegramRequestAsync(
+ accountId,
+ $"解散群组({normalizedId})",
+ () => client.Channels_DeleteChannel(input),
+ CancellationToken.None,
+ resetClientOnTimeout: false);
+
+ return true;
+ }
+
public async Task ExportJoinLinkAsync(int accountId, long groupId)
{
var client = await GetOrCreateConnectedClientAsync(accountId);
var dialogs = await client.Messages_GetAllDialogs();
- // 基础群组(Chat)
var basic = dialogs.chats.Values.OfType().FirstOrDefault(c => c.IsActive && c.id == groupId);
if (basic != null)
{
@@ -178,7 +515,6 @@ public class GroupService : IGroupService
return link;
}
- // 超级群组(Channel 但 megagroup=true,即 !IsChannel)
var mega = dialogs.chats.Values.OfType().FirstOrDefault(c => c.IsActive && !c.IsChannel && c.id == groupId);
if (mega != null)
{
@@ -206,7 +542,6 @@ public class GroupService : IGroupService
var client = await GetOrCreateConnectedClientAsync(accountId);
var dialogs = await client.Messages_GetAllDialogs();
- // 基础群组(Chat)
var basic = dialogs.chats.Values.OfType().FirstOrDefault(c => c.IsActive && c.id == groupId);
if (basic != null)
{
@@ -250,7 +585,6 @@ public class GroupService : IGroupService
.ToList();
}
- // 超级群组(Channel but !IsChannel)
var mega = dialogs.chats.Values.OfType().FirstOrDefault(c => c.IsActive && !c.IsChannel && c.id == groupId);
if (mega != null)
{
@@ -298,8 +632,10 @@ public class GroupService : IGroupService
throw new InvalidOperationException($"群组 {groupId} not found");
}
- private async Task GetOrCreateConnectedClientAsync(int accountId)
+ private async Task GetOrCreateConnectedClientAsync(int accountId, CancellationToken cancellationToken = default)
{
+ cancellationToken.ThrowIfCancellationRequested();
+
var existing = _clientPool.GetClient(accountId);
if (existing?.User != null)
return existing;
@@ -339,9 +675,22 @@ public class GroupService : IGroupService
try
{
- await client.ConnectAsync();
+ await ExecuteTelegramRequestAsync(
+ accountId,
+ "连接 Telegram",
+ () => client.ConnectAsync(),
+ cancellationToken,
+ resetClientOnTimeout: true);
+
if (client.User == null && (client.UserId != 0 || account.UserId != 0))
- await client.LoginUserIfNeeded(reloginOnFailedResume: false);
+ {
+ await ExecuteTelegramRequestAsync(
+ accountId,
+ "恢复 Telegram 登录状态",
+ () => client.LoginUserIfNeeded(reloginOnFailedResume: false),
+ cancellationToken,
+ resetClientOnTimeout: true);
+ }
}
catch (Exception ex)
{
@@ -362,6 +711,70 @@ public class GroupService : IGroupService
return client;
}
+ private TimeSpan GetTelegramRequestTimeout()
+ {
+ var seconds = int.TryParse(_configuration["Telegram:RequestTimeoutSeconds"], out var parsedSeconds)
+ ? parsedSeconds
+ : 90;
+ return TimeSpan.FromSeconds(Math.Clamp(seconds, 15, 600));
+ }
+
+ private async Task ExecuteTelegramRequestAsync(
+ int accountId,
+ string operation,
+ Func action,
+ CancellationToken cancellationToken,
+ bool resetClientOnTimeout)
+ {
+ var timeout = GetTelegramRequestTimeout();
+
+ try
+ {
+ await action().WaitAsync(timeout, cancellationToken);
+ }
+ catch (TimeoutException)
+ {
+ _logger.LogWarning(
+ "Telegram request timed out after {TimeoutSeconds}s for account {AccountId}: {Operation}",
+ timeout.TotalSeconds,
+ accountId,
+ operation);
+
+ if (resetClientOnTimeout)
+ await _clientPool.RemoveClientAsync(accountId);
+
+ throw new TimeoutException($"Telegram 请求超时:{operation} 超过 {timeout.TotalSeconds:0} 秒,可能是 Session 失效、账号受限、网络异常或代理异常");
+ }
+ }
+
+ private async Task ExecuteTelegramRequestAsync(
+ int accountId,
+ string operation,
+ Func> action,
+ CancellationToken cancellationToken,
+ bool resetClientOnTimeout)
+ {
+ var timeout = GetTelegramRequestTimeout();
+
+ try
+ {
+ return await action().WaitAsync(timeout, cancellationToken);
+ }
+ catch (TimeoutException)
+ {
+ _logger.LogWarning(
+ "Telegram request timed out after {TimeoutSeconds}s for account {AccountId}: {Operation}",
+ timeout.TotalSeconds,
+ accountId,
+ operation);
+
+ if (resetClientOnTimeout)
+ await _clientPool.RemoveClientAsync(accountId);
+
+ throw new TimeoutException($"Telegram 请求超时:{operation} 超过 {timeout.TotalSeconds:0} 秒,可能是 Session 失效、账号受限、网络异常或代理异常");
+ }
+ }
+
private int ResolveApiId(TelegramPanel.Data.Entities.Account account)
{
if (int.TryParse(_configuration["Telegram:ApiId"], out var globalApiId) && globalApiId > 0)
diff --git a/src/TelegramPanel.Data/AppDbContext.cs b/src/TelegramPanel.Data/AppDbContext.cs
index 6a449cf..0a3ab3b 100644
--- a/src/TelegramPanel.Data/AppDbContext.cs
+++ b/src/TelegramPanel.Data/AppDbContext.cs
@@ -16,9 +16,11 @@ public class AppDbContext : DbContext
public DbSet Accounts => Set();
public DbSet AccountCategories => Set();
public DbSet AccountChannels => Set();
+ public DbSet AccountGroups => Set();
public DbSet Channels => Set();
public DbSet ChannelGroups => Set();
public DbSet Groups => Set();
+ public DbSet GroupCategories => Set();
public DbSet BatchTasks => Set();
public DbSet Bots => Set();
public DbSet BotChannels => Set();
@@ -43,11 +45,8 @@ public class AppDbContext : DbContext
entity.Property(e => e.TelegramStatusDetails).HasMaxLength(2000);
entity.HasIndex(e => e.Phone).IsUnique();
- // 注意:UserId 在未登录/未验证的 session 导入场景可能为 0,SQLite 的 UNIQUE 约束会导致多账号导入失败。
- // 这里保留索引但不做唯一约束,真正的去重以 Phone 为准。
entity.HasIndex(e => e.UserId);
- // 与AccountCategory的关系
entity.HasOne(e => e.Category)
.WithMany(c => c.Accounts)
.HasForeignKey(e => e.CategoryId)
@@ -76,14 +75,15 @@ public class AppDbContext : DbContext
entity.HasIndex(e => e.TelegramId).IsUnique();
entity.HasIndex(e => e.Username);
+ entity.HasIndex(e => e.CreatorAccountId);
+ entity.HasIndex(e => e.GroupId);
+ entity.HasIndex(e => e.SyncedAt);
- // 与Account的关系(创建者)
entity.HasOne(e => e.CreatorAccount)
.WithMany(a => a.Channels)
.HasForeignKey(e => e.CreatorAccountId)
.OnDelete(DeleteBehavior.SetNull);
- // 与ChannelGroup的关系
entity.HasOne(e => e.Group)
.WithMany(g => g.Channels)
.HasForeignKey(e => e.GroupId)
@@ -94,9 +94,9 @@ public class AppDbContext : DbContext
modelBuilder.Entity(entity =>
{
entity.HasKey(e => e.Id);
-
entity.HasIndex(e => new { e.AccountId, e.ChannelId }).IsUnique();
entity.HasIndex(e => e.ChannelId);
+ entity.HasIndex(e => new { e.ChannelId, e.IsCreator, e.IsAdmin });
entity.HasOne(e => e.Account)
.WithMany(a => a.AccountChannels)
@@ -129,12 +129,48 @@ public class AppDbContext : DbContext
entity.HasIndex(e => e.TelegramId).IsUnique();
entity.HasIndex(e => e.Username);
+ entity.HasIndex(e => e.CategoryId);
+ entity.HasIndex(e => e.CreatorAccountId);
+ entity.HasIndex(e => e.SyncedAt);
- // 与Account的关系(创建者)
entity.HasOne(e => e.CreatorAccount)
.WithMany(a => a.Groups)
.HasForeignKey(e => e.CreatorAccountId)
+ .OnDelete(DeleteBehavior.SetNull);
+
+ entity.HasOne(e => e.Category)
+ .WithMany(c => c.Groups)
+ .HasForeignKey(e => e.CategoryId)
+ .OnDelete(DeleteBehavior.SetNull);
+ });
+
+ // AccountGroup配置
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasKey(e => e.Id);
+ entity.HasIndex(e => new { e.AccountId, e.GroupId }).IsUnique();
+ entity.HasIndex(e => e.GroupId);
+ entity.HasIndex(e => new { e.GroupId, e.IsCreator, e.IsAdmin });
+
+ entity.HasOne(e => e.Account)
+ .WithMany(a => a.AccountGroups)
+ .HasForeignKey(e => e.AccountId)
.OnDelete(DeleteBehavior.Cascade);
+
+ entity.HasOne(e => e.Group)
+ .WithMany(g => g.AccountGroups)
+ .HasForeignKey(e => e.GroupId)
+ .OnDelete(DeleteBehavior.Cascade);
+ });
+
+ // GroupCategory配置
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasKey(e => e.Id);
+ entity.Property(e => e.Name).IsRequired().HasMaxLength(100);
+ entity.Property(e => e.Description).HasMaxLength(500);
+
+ entity.HasIndex(e => e.Name).IsUnique();
});
// BatchTask配置
diff --git a/src/TelegramPanel.Data/Entities/Account.cs b/src/TelegramPanel.Data/Entities/Account.cs
index 80b0691..982de3b 100644
--- a/src/TelegramPanel.Data/Entities/Account.cs
+++ b/src/TelegramPanel.Data/Entities/Account.cs
@@ -24,6 +24,7 @@ public class Account
}
public long UserId { get; set; }
+
///
/// 账号昵称(Telegram 显示名称)
///
@@ -37,6 +38,16 @@ public class Account
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime LastSyncAt { get; set; } = DateTime.UtcNow;
+ ///
+ /// 通过 777000 系统通知最早消息时间估算的注册时间(非百分百准确)
+ ///
+ public DateTime? EstimatedRegistrationAt { get; set; }
+
+ ///
+ /// 最近一次尝试估算注册时间的时间(UTC)
+ ///
+ public DateTime? EstimatedRegistrationCheckedAtUtc { get; set; }
+
///
/// 最后一次登录 Telegram 的时间(UTC),用于风控检查
///
@@ -67,9 +78,22 @@ public class Account
///
public string? TwoFactorPassword { get; set; }
+ ///
+ /// 账号当前已同步的频道数量(用于列表展示,不持久化)
+ ///
+ [NotMapped]
+ public int ChannelCount { get; set; }
+
+ ///
+ /// 账号当前已同步的群组数量(用于列表展示,不持久化)
+ ///
+ [NotMapped]
+ public int GroupCount { get; set; }
+
// 导航属性
public AccountCategory? Category { get; set; }
public ICollection Channels { get; set; } = new List();
public ICollection AccountChannels { get; set; } = new List();
public ICollection Groups { get; set; } = new List();
+ public ICollection AccountGroups { get; set; } = new List();
}
diff --git a/src/TelegramPanel.Data/Entities/AccountGroup.cs b/src/TelegramPanel.Data/Entities/AccountGroup.cs
new file mode 100644
index 0000000..18ed80f
--- /dev/null
+++ b/src/TelegramPanel.Data/Entities/AccountGroup.cs
@@ -0,0 +1,27 @@
+namespace TelegramPanel.Data.Entities;
+
+///
+/// 账号-群组关联
+///
+public class AccountGroup
+{
+ public int Id { get; set; }
+
+ public int AccountId { get; set; }
+ public int GroupId { get; set; }
+
+ ///
+ /// 是否为创建者(拥有者)
+ ///
+ public bool IsCreator { get; set; }
+
+ ///
+ /// 是否为管理员(包含创建者)
+ ///
+ public bool IsAdmin { get; set; }
+
+ public DateTime SyncedAt { get; set; } = DateTime.UtcNow;
+
+ public Account Account { get; set; } = null!;
+ public Group Group { get; set; } = null!;
+}
diff --git a/src/TelegramPanel.Data/Entities/Group.cs b/src/TelegramPanel.Data/Entities/Group.cs
index 2a9a91c..e34eb8e 100644
--- a/src/TelegramPanel.Data/Entities/Group.cs
+++ b/src/TelegramPanel.Data/Entities/Group.cs
@@ -12,10 +12,13 @@ public class Group
public string? Username { get; set; }
public int MemberCount { get; set; }
public string? About { get; set; }
- public int CreatorAccountId { get; set; }
+ public int? CreatorAccountId { get; set; }
+ public int? CategoryId { get; set; }
public DateTime? CreatedAt { get; set; }
public DateTime SyncedAt { get; set; } = DateTime.UtcNow;
// 导航属性
- public Account CreatorAccount { get; set; } = null!;
+ public Account? CreatorAccount { get; set; }
+ public GroupCategory? Category { get; set; }
+ public ICollection AccountGroups { get; set; } = new List();
}
diff --git a/src/TelegramPanel.Data/Entities/GroupCategory.cs b/src/TelegramPanel.Data/Entities/GroupCategory.cs
new file mode 100644
index 0000000..fd983bb
--- /dev/null
+++ b/src/TelegramPanel.Data/Entities/GroupCategory.cs
@@ -0,0 +1,15 @@
+namespace TelegramPanel.Data.Entities;
+
+///
+/// 群组分类实体
+///
+public class GroupCategory
+{
+ public int Id { get; set; }
+ public string Name { get; set; } = null!;
+ public string? Description { get; set; }
+ public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
+
+ // 导航属性
+ public ICollection Groups { get; set; } = new List();
+}
diff --git a/src/TelegramPanel.Data/Migrations/20260308155832_AddAccountGroups.Designer.cs b/src/TelegramPanel.Data/Migrations/20260308155832_AddAccountGroups.Designer.cs
new file mode 100644
index 0000000..d56c9e6
--- /dev/null
+++ b/src/TelegramPanel.Data/Migrations/20260308155832_AddAccountGroups.Designer.cs
@@ -0,0 +1,688 @@
+//
+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("20260308155832_AddAccountGroups")]
+ partial class AddAccountGroups
+ {
+ ///
+ 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("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.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.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("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("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.Group", 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("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("CreatorAccountId");
+
+ b.HasIndex("TelegramId")
+ .IsUnique();
+
+ b.HasIndex("Username");
+
+ b.ToTable("Groups");
+ });
+
+ 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.Group", b =>
+ {
+ b.HasOne("TelegramPanel.Data.Entities.Account", "CreatorAccount")
+ .WithMany("Groups")
+ .HasForeignKey("CreatorAccountId")
+ .OnDelete(DeleteBehavior.SetNull);
+
+ 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.Group", b =>
+ {
+ b.Navigation("AccountGroups");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/TelegramPanel.Data/Migrations/20260308155832_AddAccountGroups.cs b/src/TelegramPanel.Data/Migrations/20260308155832_AddAccountGroups.cs
new file mode 100644
index 0000000..2fa9c9f
--- /dev/null
+++ b/src/TelegramPanel.Data/Migrations/20260308155832_AddAccountGroups.cs
@@ -0,0 +1,125 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace TelegramPanel.Data.Migrations
+{
+ ///
+ public partial class AddAccountGroups : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropForeignKey(
+ name: "FK_Groups_Accounts_CreatorAccountId",
+ table: "Groups");
+
+ migrationBuilder.AlterColumn(
+ name: "CreatorAccountId",
+ table: "Groups",
+ type: "INTEGER",
+ nullable: true,
+ oldClrType: typeof(int),
+ oldType: "INTEGER");
+
+ migrationBuilder.CreateTable(
+ name: "AccountGroups",
+ columns: table => new
+ {
+ Id = table.Column(type: "INTEGER", nullable: false)
+ .Annotation("Sqlite:Autoincrement", true),
+ AccountId = table.Column(type: "INTEGER", nullable: false),
+ GroupId = table.Column(type: "INTEGER", nullable: false),
+ IsCreator = table.Column(type: "INTEGER", nullable: false),
+ IsAdmin = table.Column(type: "INTEGER", nullable: false),
+ SyncedAt = table.Column(type: "TEXT", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_AccountGroups", x => x.Id);
+ table.ForeignKey(
+ name: "FK_AccountGroups_Accounts_AccountId",
+ column: x => x.AccountId,
+ principalTable: "Accounts",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ table.ForeignKey(
+ name: "FK_AccountGroups_Groups_GroupId",
+ column: x => x.GroupId,
+ principalTable: "Groups",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_AccountGroups_AccountId_GroupId",
+ table: "AccountGroups",
+ columns: new[] { "AccountId", "GroupId" },
+ unique: true);
+
+ migrationBuilder.CreateIndex(
+ name: "IX_AccountGroups_GroupId",
+ table: "AccountGroups",
+ column: "GroupId");
+
+ migrationBuilder.Sql(@"
+INSERT OR IGNORE INTO AccountGroups (AccountId, GroupId, IsCreator, IsAdmin, SyncedAt)
+SELECT CreatorAccountId, Id, 1, 1, SyncedAt
+FROM Groups
+WHERE CreatorAccountId IS NOT NULL;
+");
+
+ migrationBuilder.AddForeignKey(
+ name: "FK_Groups_Accounts_CreatorAccountId",
+ table: "Groups",
+ column: "CreatorAccountId",
+ principalTable: "Accounts",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.SetNull);
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropForeignKey(
+ name: "FK_Groups_Accounts_CreatorAccountId",
+ table: "Groups");
+
+ migrationBuilder.Sql(@"
+UPDATE Groups
+SET CreatorAccountId = (
+ SELECT AccountId
+ FROM AccountGroups
+ WHERE GroupId = Groups.Id AND IsCreator = 1
+ ORDER BY SyncedAt DESC
+ LIMIT 1
+)
+WHERE CreatorAccountId IS NULL;
+");
+
+ migrationBuilder.Sql(@"DELETE FROM Groups WHERE CreatorAccountId IS NULL;");
+
+ migrationBuilder.DropTable(
+ name: "AccountGroups");
+
+ migrationBuilder.AlterColumn(
+ name: "CreatorAccountId",
+ table: "Groups",
+ type: "INTEGER",
+ nullable: false,
+ defaultValue: 0,
+ oldClrType: typeof(int),
+ oldType: "INTEGER",
+ oldNullable: true);
+
+ migrationBuilder.AddForeignKey(
+ name: "FK_Groups_Accounts_CreatorAccountId",
+ table: "Groups",
+ column: "CreatorAccountId",
+ principalTable: "Accounts",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ }
+ }
+}
diff --git a/src/TelegramPanel.Data/Migrations/20260308172014_AddGroupCategories.Designer.cs b/src/TelegramPanel.Data/Migrations/20260308172014_AddGroupCategories.Designer.cs
new file mode 100644
index 0000000..dab3837
--- /dev/null
+++ b/src/TelegramPanel.Data/Migrations/20260308172014_AddGroupCategories.Designer.cs
@@ -0,0 +1,731 @@
+//
+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("20260308172014_AddGroupCategories")]
+ partial class AddGroupCategories
+ {
+ ///
+ 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("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.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.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("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("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.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("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("TelegramId")
+ .IsUnique();
+
+ b.HasIndex("Username");
+
+ b.ToTable("Groups");
+ });
+
+ modelBuilder.Entity("TelegramPanel.Data.Entities.GroupCategory", b =>
+ {
+ b.Property