├── screenshot ├── account.png ├── Dashboard.png ├── equipment.png ├── Create channel.png ├── Import account.png ├── System notification.png ├── BOT management channel.png ├── Invite users in batches.png ├── Login with mobile phone number.png └── Set up administrators in batches.png ├── global.json ├── .dockerignore ├── src ├── TelegramPanel.Web │ ├── Services │ │ ├── PanelTimeZoneOptions.cs │ │ ├── AdminAuthOptions.cs │ │ ├── AdminAuthHelpers.cs │ │ ├── AppRestartService.cs │ │ ├── LocalConfigFile.cs │ │ ├── AccountDataAutoSyncBackgroundService.cs │ │ ├── UiPreferencesService.cs │ │ ├── PanelTimeZoneService.cs │ │ ├── DataSyncService.cs │ │ └── BotAutoSyncBackgroundService.cs │ ├── Components │ │ ├── Dialogs │ │ │ ├── AccountCategoryEditModel.cs │ │ │ ├── ExternalApiCurlSampleDialog.razor │ │ │ ├── BotBatchSetCategoryDialog.razor │ │ │ ├── BatchSetAccountCategoryDialog.razor │ │ │ ├── AccountCategoryEditDialog.razor │ │ │ ├── BotChannelCategoryDialog.razor │ │ │ ├── SystemInboxDialog.razor │ │ │ ├── CreateExternalApiDialog.razor │ │ │ ├── ModulePageDialog.razor │ │ │ ├── BotBanMemberDialog.razor │ │ │ ├── RiskWarningDialog.razor │ │ │ ├── EditGenericApiDialog.razor │ │ │ ├── ChannelEditDialog.razor │ │ │ └── BotChannelEditDialog.razor │ │ ├── Routes.razor │ │ ├── Shared │ │ │ └── PanelTime.razor │ │ ├── App.razor │ │ ├── _Imports.razor │ │ ├── Layout │ │ │ ├── MainLayout.razor │ │ │ └── NavMenu.razor │ │ └── Pages │ │ │ ├── ModulePageHost.razor │ │ │ └── AdminPassword.razor │ ├── Modules │ │ ├── ModuleApplicationExtensions.cs │ │ ├── ModuleRegistry.cs │ │ ├── BuiltIn │ │ │ ├── BuiltInModuleCatalog.cs │ │ │ ├── TaskCatalogModule.cs │ │ │ └── KickApiModule.cs │ │ ├── ModuleStateStore.cs │ │ ├── ModulePaths.cs │ │ └── ModuleLoadContext.cs │ ├── Properties │ │ └── launchSettings.json │ ├── _Imports.razor │ ├── wwwroot │ │ └── app.js │ ├── appsettings.json │ ├── ExternalApi │ │ ├── ExternalApiCatalog.cs │ │ └── ExternalApiModels.cs │ └── TelegramPanel.Web.csproj ├── TelegramPanel.Core │ ├── Models │ │ ├── TelegramSystemMessage.cs │ │ ├── ChannelAdminInfo.cs │ │ ├── GroupInfo.cs │ │ ├── TelegramAuthorizationInfo.cs │ │ ├── AccountInfo.cs │ │ ├── ChannelInfo.cs │ │ ├── BatchTaskInfo.cs │ │ └── TelegramAccountStatusResult.cs │ ├── Services │ │ ├── RiskWarningAction.cs │ │ ├── ChannelGroupManagementService.cs │ │ ├── AccountCategoryManagementService.cs │ │ ├── GroupManagementService.cs │ │ ├── BatchTaskManagementService.cs │ │ └── AccountRiskService.cs │ ├── BatchTasks │ │ └── BatchTaskTypes.cs │ ├── Interfaces │ │ ├── IGroupService.cs │ │ ├── ITelegramClientPool.cs │ │ ├── ISessionImporter.cs │ │ ├── IAccountService.cs │ │ └── IChannelService.cs │ ├── TelegramPanel.Core.csproj │ ├── Utils │ │ └── PhoneNumberFormatter.cs │ └── ServiceCollectionExtensions.cs ├── TelegramPanel.Data │ ├── Repositories │ │ ├── IBotRepository.cs │ │ ├── IChannelGroupRepository.cs │ │ ├── IAccountCategoryRepository.cs │ │ ├── IBotChannelRepository.cs │ │ ├── IGroupRepository.cs │ │ ├── IBotChannelCategoryRepository.cs │ │ ├── IBatchTaskRepository.cs │ │ ├── IAccountRepository.cs │ │ ├── IAccountChannelRepository.cs │ │ ├── IRepository.cs │ │ ├── IChannelRepository.cs │ │ ├── ChannelGroupRepository.cs │ │ ├── AccountCategoryRepository.cs │ │ ├── BotRepository.cs │ │ ├── BotChannelCategoryRepository.cs │ │ ├── BotChannelRepository.cs │ │ ├── BatchTaskRepository.cs │ │ ├── GroupRepository.cs │ │ ├── Repository.cs │ │ ├── AccountRepository.cs │ │ ├── AccountChannelRepository.cs │ │ └── ChannelRepository.cs │ ├── Entities │ │ ├── ChannelGroup.cs │ │ ├── AccountCategory.cs │ │ ├── BotChannelCategory.cs │ │ ├── Group.cs │ │ ├── BatchTask.cs │ │ ├── AccountChannel.cs │ │ ├── Bot.cs │ │ ├── BotChannel.cs │ │ ├── Channel.cs │ │ ├── Account.cs │ │ └── AccountExtensions.cs │ ├── TelegramPanel.Data.csproj │ ├── Migrations │ │ ├── 20251219000002_AddBotLastUpdateId.cs │ │ ├── 20251220000000_AddLastLoginAt.cs │ │ ├── 20251219000004_AccountUserIdIndexNotUnique.cs │ │ └── 20251219000003_AddAccountTelegramStatusCache.cs │ └── ServiceCollectionExtensions.cs └── TelegramPanel.Modules.Abstractions │ ├── TelegramPanel.Modules.Abstractions.csproj │ ├── ITelegramPanelModule.cs │ ├── ModuleManifest.cs │ ├── ModuleContributions.cs │ └── SemVer.cs ├── docs ├── README.md ├── sync.md ├── import.md ├── database.md ├── api.md ├── advanced.md └── reverse-proxy.md ├── Dockerfile ├── docker-compose.yml ├── Dockerfile.local ├── .gitignore ├── TelegramPanel.sln └── tools └── package-module.ps1 /screenshot/account.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moeacgx/Telegram-Panel/HEAD/screenshot/account.png -------------------------------------------------------------------------------- /screenshot/Dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moeacgx/Telegram-Panel/HEAD/screenshot/Dashboard.png -------------------------------------------------------------------------------- /screenshot/equipment.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moeacgx/Telegram-Panel/HEAD/screenshot/equipment.png -------------------------------------------------------------------------------- /global.json: -------------------------------------------------------------------------------- 1 | { 2 | "sdk": { 3 | "version": "8.0.416", 4 | "rollForward": "latestPatch" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /screenshot/Create channel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moeacgx/Telegram-Panel/HEAD/screenshot/Create channel.png -------------------------------------------------------------------------------- /screenshot/Import account.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moeacgx/Telegram-Panel/HEAD/screenshot/Import account.png -------------------------------------------------------------------------------- /screenshot/System notification.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moeacgx/Telegram-Panel/HEAD/screenshot/System notification.png -------------------------------------------------------------------------------- /screenshot/BOT management channel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moeacgx/Telegram-Panel/HEAD/screenshot/BOT management channel.png -------------------------------------------------------------------------------- /screenshot/Invite users in batches.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moeacgx/Telegram-Panel/HEAD/screenshot/Invite users in batches.png -------------------------------------------------------------------------------- /screenshot/Login with mobile phone number.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moeacgx/Telegram-Panel/HEAD/screenshot/Login with mobile phone number.png -------------------------------------------------------------------------------- /screenshot/Set up administrators in batches.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moeacgx/Telegram-Panel/HEAD/screenshot/Set up administrators in batches.png -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | .serena 3 | .tmp 4 | **/bin 5 | **/obj 6 | docker-data 7 | logs 8 | sessions 9 | todolist 10 | *.db 11 | *.log 12 | *.user 13 | *.suo 14 | *.cache 15 | 16 | -------------------------------------------------------------------------------- /src/TelegramPanel.Web/Services/PanelTimeZoneOptions.cs: -------------------------------------------------------------------------------- 1 | namespace TelegramPanel.Web.Services; 2 | 3 | public sealed class PanelTimeZoneOptions 4 | { 5 | public string? TimeZoneId { get; set; } 6 | } 7 | 8 | -------------------------------------------------------------------------------- /src/TelegramPanel.Web/Components/Dialogs/AccountCategoryEditModel.cs: -------------------------------------------------------------------------------- 1 | namespace TelegramPanel.Web.Components.Dialogs; 2 | 3 | public sealed record AccountCategoryEditModel(string Name, string? Color, string? Description); 4 | 5 | -------------------------------------------------------------------------------- /src/TelegramPanel.Core/Models/TelegramSystemMessage.cs: -------------------------------------------------------------------------------- 1 | namespace TelegramPanel.Core.Models; 2 | 3 | /// 4 | /// Telegram 系统通知(常用于接收登录验证码,来自 777000 对话) 5 | /// 6 | public record TelegramSystemMessage( 7 | int Id, 8 | DateTime? DateUtc, 9 | string Text 10 | ); 11 | 12 | -------------------------------------------------------------------------------- /src/TelegramPanel.Data/Repositories/IBotRepository.cs: -------------------------------------------------------------------------------- 1 | using TelegramPanel.Data.Entities; 2 | 3 | namespace TelegramPanel.Data.Repositories; 4 | 5 | public interface IBotRepository : IRepository 6 | { 7 | Task GetByNameAsync(string name); 8 | Task> GetAllWithStatsAsync(); 9 | } 10 | 11 | -------------------------------------------------------------------------------- /src/TelegramPanel.Data/Repositories/IChannelGroupRepository.cs: -------------------------------------------------------------------------------- 1 | using TelegramPanel.Data.Entities; 2 | 3 | namespace TelegramPanel.Data.Repositories; 4 | 5 | /// 6 | /// 频道分组仓储接口 7 | /// 8 | public interface IChannelGroupRepository : IRepository 9 | { 10 | Task GetByNameAsync(string name); 11 | } 12 | -------------------------------------------------------------------------------- /src/TelegramPanel.Web/Components/Routes.razor: -------------------------------------------------------------------------------- 1 | @using TelegramPanel.Web.Components.Layout 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/TelegramPanel.Data/Repositories/IAccountCategoryRepository.cs: -------------------------------------------------------------------------------- 1 | using TelegramPanel.Data.Entities; 2 | 3 | namespace TelegramPanel.Data.Repositories; 4 | 5 | /// 6 | /// 账号分类仓储接口 7 | /// 8 | public interface IAccountCategoryRepository : IRepository 9 | { 10 | Task GetByNameAsync(string name); 11 | } 12 | -------------------------------------------------------------------------------- /src/TelegramPanel.Core/Services/RiskWarningAction.cs: -------------------------------------------------------------------------------- 1 | namespace TelegramPanel.Core.Services; 2 | 3 | /// 4 | /// 风控警告对话框的用户操作 5 | /// 6 | public enum RiskWarningAction 7 | { 8 | /// 9 | /// 继续操作(包含风险账号) 10 | /// 11 | Continue, 12 | 13 | /// 14 | /// 排除风险账号后继续 15 | /// 16 | ExcludeRisky 17 | } 18 | -------------------------------------------------------------------------------- /src/TelegramPanel.Data/Repositories/IBotChannelRepository.cs: -------------------------------------------------------------------------------- 1 | using TelegramPanel.Data.Entities; 2 | 3 | namespace TelegramPanel.Data.Repositories; 4 | 5 | public interface IBotChannelRepository : IRepository 6 | { 7 | Task GetByTelegramIdAsync(int botId, long telegramId); 8 | Task> GetForBotAsync(int botId, int? categoryId = null); 9 | } 10 | 11 | -------------------------------------------------------------------------------- /src/TelegramPanel.Data/Repositories/IGroupRepository.cs: -------------------------------------------------------------------------------- 1 | using TelegramPanel.Data.Entities; 2 | 3 | namespace TelegramPanel.Data.Repositories; 4 | 5 | /// 6 | /// 群组仓储接口 7 | /// 8 | public interface IGroupRepository : IRepository 9 | { 10 | Task GetByTelegramIdAsync(long telegramId); 11 | Task> GetByCreatorAccountAsync(int accountId); 12 | } 13 | -------------------------------------------------------------------------------- /src/TelegramPanel.Data/Repositories/IBotChannelCategoryRepository.cs: -------------------------------------------------------------------------------- 1 | using TelegramPanel.Data.Entities; 2 | 3 | namespace TelegramPanel.Data.Repositories; 4 | 5 | public interface IBotChannelCategoryRepository : IRepository 6 | { 7 | Task> GetForBotAsync(int botId); 8 | Task GetByNameAsync(int botId, string name); 9 | } 10 | 11 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # 文档索引 2 | 3 | 如果你只是想快速跑起来:优先看根目录 `README.md` 的 Docker 一键安装。 4 | 5 | ## 目录 6 | 7 | - `docs/import.md`:压缩包批量导入账号结构(推荐) 8 | - `docs/sync.md`:同步功能说明(同步什么/为什么要同步/自动同步) 9 | - `docs/reverse-proxy.md`:反向代理示例(Nginx/Caddy,含 WebSocket) 10 | - `docs/api.md`:接口速查(面向二次开发) 11 | - `docs/database.md`:数据库与主要表结构(面向排障/扩展) 12 | - `docs/advanced.md`:架构/配置项/环境变量/数据持久化位置等 13 | - `docs/modules.md`:模块系统(可安装/可卸载/版本与依赖/回滚) 14 | -------------------------------------------------------------------------------- /src/TelegramPanel.Web/Components/Shared/PanelTime.razor: -------------------------------------------------------------------------------- 1 | @inject TelegramPanel.Web.Services.PanelTimeZoneService TimeZone 2 | 3 | @Text 4 | 5 | @code 6 | { 7 | [Parameter] public DateTime? Value { get; set; } 8 | [Parameter] public string Format { get; set; } = "yyyy-MM-dd HH:mm"; 9 | [Parameter] public string EmptyText { get; set; } = "-"; 10 | 11 | private string Text => TimeZone.Format(Value, Format, EmptyText); 12 | } 13 | 14 | -------------------------------------------------------------------------------- /src/TelegramPanel.Web/Modules/ModuleApplicationExtensions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Builder; 2 | using Microsoft.Extensions.DependencyInjection; 3 | 4 | namespace TelegramPanel.Web.Modules; 5 | 6 | public static class ModuleApplicationExtensions 7 | { 8 | public static void MapInstalledModules(this WebApplication app) 9 | { 10 | var registry = app.Services.GetRequiredService(); 11 | registry.MapEndpoints(app); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/TelegramPanel.Data/Repositories/IBatchTaskRepository.cs: -------------------------------------------------------------------------------- 1 | using TelegramPanel.Data.Entities; 2 | 3 | namespace TelegramPanel.Data.Repositories; 4 | 5 | /// 6 | /// 批量任务仓储接口 7 | /// 8 | public interface IBatchTaskRepository : IRepository 9 | { 10 | Task> GetByStatusAsync(string status); 11 | Task> GetRunningTasksAsync(); 12 | Task> GetRecentTasksAsync(int count = 20); 13 | } 14 | -------------------------------------------------------------------------------- /src/TelegramPanel.Data/Entities/ChannelGroup.cs: -------------------------------------------------------------------------------- 1 | namespace TelegramPanel.Data.Entities; 2 | 3 | /// 4 | /// 频道分组实体 5 | /// 6 | public class ChannelGroup 7 | { 8 | public int Id { get; set; } 9 | public string Name { get; set; } = null!; 10 | public string? Description { get; set; } 11 | public DateTime CreatedAt { get; set; } = DateTime.UtcNow; 12 | 13 | // 导航属性 14 | public ICollection Channels { get; set; } = new List(); 15 | } 16 | -------------------------------------------------------------------------------- /src/TelegramPanel.Data/Repositories/IAccountRepository.cs: -------------------------------------------------------------------------------- 1 | using TelegramPanel.Data.Entities; 2 | 3 | namespace TelegramPanel.Data.Repositories; 4 | 5 | /// 6 | /// 账号仓储接口 7 | /// 8 | public interface IAccountRepository : IRepository 9 | { 10 | Task GetByPhoneAsync(string phone); 11 | Task GetByUserIdAsync(long userId); 12 | Task> GetByCategoryAsync(int categoryId); 13 | Task> GetActiveAccountsAsync(); 14 | } 15 | -------------------------------------------------------------------------------- /docs/sync.md: -------------------------------------------------------------------------------- 1 | # “同步”功能说明 2 | 3 | ## 同步到底同步什么? 4 | 5 | 同步 = 从 Telegram 拉取并更新本地数据库中的“账号创建的数据”,主要用于**列表展示/筛选/分组/批量操作**: 6 | 7 | - 账号创建的频道(Channel) 8 | - 账号创建的群组(Group) 9 | 10 | 它不是: 11 | 12 | - 手机号登录/收验证码 13 | - 检测账号是否冻结/封禁 14 | - 同步消息/聊天记录 15 | 16 | ## 为什么需要同步? 17 | 18 | 面板的频道/群组列表、分类、批量任务都依赖本地数据库。同步负责把 Telegram 侧的最新信息拉下来并落库。 19 | 20 | ## 自动同步是什么? 21 | 22 | “自动同步”就是定时在后台执行同样的同步逻辑。 23 | 24 | - 默认关闭:避免频繁调用 Telegram API。 25 | - 开启后会写入本地覆盖配置(Docker 下在 `./docker-data/appsettings.local.json`)。 26 | 27 | 如果你不需要自动同步:保持关闭,平时用手动同步即可。 28 | -------------------------------------------------------------------------------- /src/TelegramPanel.Web/Services/AdminAuthOptions.cs: -------------------------------------------------------------------------------- 1 | namespace TelegramPanel.Web.Services; 2 | 3 | public sealed class AdminAuthOptions 4 | { 5 | public bool Enabled { get; set; } 6 | public string InitialUsername { get; set; } = "admin"; 7 | public string InitialPassword { get; set; } = "admin123"; 8 | public string CredentialsPath { get; set; } = "admin_auth.json"; 9 | 10 | public bool IsConfigured => 11 | !string.IsNullOrWhiteSpace(InitialUsername) && !string.IsNullOrWhiteSpace(InitialPassword); 12 | } 13 | -------------------------------------------------------------------------------- /src/TelegramPanel.Data/Entities/AccountCategory.cs: -------------------------------------------------------------------------------- 1 | namespace TelegramPanel.Data.Entities; 2 | 3 | /// 4 | /// 账号分类实体 5 | /// 6 | public class AccountCategory 7 | { 8 | public int Id { get; set; } 9 | public string Name { get; set; } = null!; 10 | public string? Color { get; set; } 11 | public string? Description { get; set; } 12 | public DateTime CreatedAt { get; set; } = DateTime.UtcNow; 13 | 14 | // 导航属性 15 | public ICollection Accounts { get; set; } = new List(); 16 | } 17 | -------------------------------------------------------------------------------- /src/TelegramPanel.Core/BatchTasks/BatchTaskTypes.cs: -------------------------------------------------------------------------------- 1 | namespace TelegramPanel.Core.BatchTasks; 2 | 3 | /// 4 | /// 批量任务类型常量(数据库中 BatchTask.TaskType 的取值)。 5 | /// 6 | public static class BatchTaskTypes 7 | { 8 | // Bot 任务(现有) 9 | public const string Invite = "invite"; 10 | public const string SetAdmin = "set_admin"; 11 | 12 | // User 任务(新增) 13 | public const string UserJoinSubscribe = "user_join_subscribe"; 14 | 15 | // External API(记录到任务中心) 16 | public const string ExternalApiKick = "external_api_kick"; 17 | } 18 | -------------------------------------------------------------------------------- /src/TelegramPanel.Data/Repositories/IAccountChannelRepository.cs: -------------------------------------------------------------------------------- 1 | using TelegramPanel.Data.Entities; 2 | 3 | namespace TelegramPanel.Data.Repositories; 4 | 5 | /// 6 | /// 账号-频道关联仓储接口 7 | /// 8 | public interface IAccountChannelRepository : IRepository 9 | { 10 | Task GetAsync(int accountId, int channelId); 11 | Task UpsertAsync(AccountChannel link); 12 | Task DeleteForAccountExceptAsync(int accountId, IReadOnlyCollection keepChannelIds); 13 | Task GetPreferredAdminAccountIdAsync(int channelId); 14 | } 15 | -------------------------------------------------------------------------------- /src/TelegramPanel.Data/Entities/BotChannelCategory.cs: -------------------------------------------------------------------------------- 1 | namespace TelegramPanel.Data.Entities; 2 | 3 | /// 4 | /// Bot 频道分类(独立于账号频道分类/分组) 5 | /// 6 | public class BotChannelCategory 7 | { 8 | public int Id { get; set; } 9 | public int BotId { get; set; } 10 | public string Name { get; set; } = null!; 11 | public string? Description { get; set; } 12 | public DateTime CreatedAt { get; set; } = DateTime.UtcNow; 13 | 14 | public Bot? Bot { get; set; } 15 | public ICollection Channels { get; set; } = new List(); 16 | } 17 | 18 | -------------------------------------------------------------------------------- /src/TelegramPanel.Data/Repositories/IRepository.cs: -------------------------------------------------------------------------------- 1 | using System.Linq.Expressions; 2 | 3 | namespace TelegramPanel.Data.Repositories; 4 | 5 | /// 6 | /// 通用仓储接口 7 | /// 8 | public interface IRepository where T : class 9 | { 10 | Task GetByIdAsync(int id); 11 | Task> GetAllAsync(); 12 | Task> FindAsync(Expression> predicate); 13 | Task AddAsync(T entity); 14 | Task UpdateAsync(T entity); 15 | Task DeleteAsync(T entity); 16 | Task CountAsync(Expression>? predicate = null); 17 | } 18 | -------------------------------------------------------------------------------- /src/TelegramPanel.Web/Services/AdminAuthHelpers.cs: -------------------------------------------------------------------------------- 1 | namespace TelegramPanel.Web.Services; 2 | 3 | public static class AdminAuthHelpers 4 | { 5 | public static bool IsLocalReturnUrl(string? returnUrl) 6 | { 7 | if (string.IsNullOrWhiteSpace(returnUrl)) 8 | return false; 9 | 10 | // 仅允许站内跳转,避免 open redirect 11 | if (!returnUrl.StartsWith("/", StringComparison.Ordinal)) 12 | return false; 13 | 14 | if (returnUrl.StartsWith("//", StringComparison.Ordinal)) 15 | return false; 16 | 17 | return true; 18 | } 19 | } 20 | 21 | -------------------------------------------------------------------------------- /src/TelegramPanel.Modules.Abstractions/TelegramPanel.Modules.Abstractions.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | TelegramPanel.Modules 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /docs/import.md: -------------------------------------------------------------------------------- 1 | # 压缩包导入(推荐) 2 | 3 | 支持批量导入账号压缩包:**每个账号一个独立子文件夹**,文件夹内包含一个 `.json` + 一个 `.session`。 4 | 5 | ## 批量导入压缩包结构(示例) 6 | 7 | 例如你要导入 2 个账号: 8 | 9 | ``` 10 | accounts.zip 11 | ├─ 8613111111111 12 | │ ├─ 8613111111111.json 13 | │ └─ 8613111111111.session 14 | └─ 8615119714541 15 | ├─ 8615119714541.json 16 | └─ 8615119714541.session 17 | ``` 18 | 19 | ### 命名规则 20 | 21 | - 子文件夹名建议使用手机号(或你能区分账号的唯一标识)。 22 | - `.json` 与 `.session` 文件名建议与子文件夹同名(上面的例子就是同名)。 23 | - 每个子文件夹内只要能找到 **1 个 `.json` + 1 个 `.session`** 即可。 24 | 25 | ## Docker 部署下导入的文件会存哪? 26 | 27 | 如果你使用 Docker,所有 session 会写入: 28 | 29 | - `./docker-data/sessions/` 30 | 31 | 不要手工乱改里面的文件名,避免程序找不到 session。 32 | -------------------------------------------------------------------------------- /src/TelegramPanel.Data/Repositories/IChannelRepository.cs: -------------------------------------------------------------------------------- 1 | using TelegramPanel.Data.Entities; 2 | 3 | namespace TelegramPanel.Data.Repositories; 4 | 5 | /// 6 | /// 频道仓储接口 7 | /// 8 | public interface IChannelRepository : IRepository 9 | { 10 | Task GetByTelegramIdAsync(long telegramId); 11 | Task> GetCreatedAsync(); 12 | Task> GetByCreatorAccountAsync(int accountId); 13 | Task> GetForAccountAsync(int accountId, bool includeNonCreator); 14 | Task> GetByGroupAsync(int groupId); 15 | Task> GetBroadcastChannelsAsync(); 16 | } 17 | -------------------------------------------------------------------------------- /src/TelegramPanel.Core/Models/ChannelAdminInfo.cs: -------------------------------------------------------------------------------- 1 | namespace TelegramPanel.Core.Models; 2 | 3 | public record ChannelAdminInfo( 4 | long UserId, 5 | string? Username, 6 | string? FirstName, 7 | string? LastName, 8 | bool IsCreator, 9 | string? Rank 10 | ) 11 | { 12 | public string DisplayName 13 | { 14 | get 15 | { 16 | var full = $"{FirstName} {LastName}".Trim(); 17 | if (!string.IsNullOrWhiteSpace(full)) 18 | return full; 19 | if (!string.IsNullOrWhiteSpace(Username)) 20 | return $"@{Username}"; 21 | return UserId.ToString(); 22 | } 23 | } 24 | } 25 | 26 | -------------------------------------------------------------------------------- /docs/database.md: -------------------------------------------------------------------------------- 1 | # 数据库说明(简版) 2 | 3 | 默认使用 SQLite(Docker 下持久化到 `./docker-data/telegram-panel.db`)。 4 | 5 | 本页只列出核心表的“概念与用途”,避免把 README 写得太劝退;具体字段以 `src/TelegramPanel.Data/Migrations/` 为准。 6 | 7 | ## 核心表 8 | 9 | - `Accounts`:账号信息、分类、最近状态检测结果缓存等 10 | - `Channels`:频道信息(主要是账号创建的频道)与分组/展示字段 11 | - `Groups`:群组信息(主要是账号创建的群组) 12 | - `Bots` / `BotChannels`:机器人与其管理的频道(如果启用机器人管理) 13 | - `BatchTasks`:批量任务(pending/running/completed/failed) 14 | - `TaskLogs`:任务日志(用于任务中心展示与排障) 15 | 16 | ## 常见问题 17 | 18 | ### Docker 下数据库/Session 在哪? 19 | 20 | 统一在 `./docker-data`: 21 | 22 | - `./docker-data/telegram-panel.db` 23 | - `./docker-data/sessions/` 24 | 25 | ### 为什么刷新页面任务还在跑? 26 | 27 | 批量任务由后台服务从数据库拉取并执行,前端只是提交任务与展示进度(见 `BatchTasks`/`TaskLogs`)。 28 | -------------------------------------------------------------------------------- /src/TelegramPanel.Data/Entities/Group.cs: -------------------------------------------------------------------------------- 1 | namespace TelegramPanel.Data.Entities; 2 | 3 | /// 4 | /// 群组实体 5 | /// 6 | public class Group 7 | { 8 | public int Id { get; set; } 9 | public long TelegramId { get; set; } 10 | public long? AccessHash { get; set; } 11 | public string Title { get; set; } = null!; 12 | public string? Username { get; set; } 13 | public int MemberCount { get; set; } 14 | public string? About { get; set; } 15 | public int CreatorAccountId { get; set; } 16 | public DateTime? CreatedAt { get; set; } 17 | public DateTime SyncedAt { get; set; } = DateTime.UtcNow; 18 | 19 | // 导航属性 20 | public Account CreatorAccount { get; set; } = null!; 21 | } 22 | -------------------------------------------------------------------------------- /docs/api.md: -------------------------------------------------------------------------------- 1 | # 接口速查(简版) 2 | 3 | 本页用于二次开发/排查问题时快速定位接口;完整行为以代码为准。 4 | 5 | ## 账号 6 | 7 | - `GET /api/accounts`:获取账号列表 8 | - `GET /api/accounts/{id}`:获取账号详情 9 | - `POST /api/accounts/login`:手机号登录(发送验证码) 10 | - `POST /api/accounts/verify`:提交验证码/2FA 11 | - `POST /api/accounts/import`:导入账号(Session/压缩包) 12 | - `POST /api/accounts/{id}/sync`:同步该账号“创建的频道/群组” 13 | - `DELETE /api/accounts/{id}`:删除账号 14 | 15 | ## 频道/群组 16 | 17 | - `GET /api/channels`:频道列表(筛选) 18 | - `GET /api/channels/{id}`:频道详情 19 | - `POST /api/channels/{id}/admins`:设置管理员 20 | - `POST /api/channels/{id}/invite`:邀请用户/Bot 21 | 22 | ## 任务 23 | 24 | - `GET /api/tasks`:任务列表 25 | - `GET /api/tasks/{id}`:任务详情 26 | - `POST /api/tasks/{id}/cancel`:取消 27 | - `POST /api/tasks/{id}/retry`:重试 28 | -------------------------------------------------------------------------------- /src/TelegramPanel.Data/Entities/BatchTask.cs: -------------------------------------------------------------------------------- 1 | namespace TelegramPanel.Data.Entities; 2 | 3 | /// 4 | /// 批量任务实体 5 | /// 6 | public class BatchTask 7 | { 8 | public int Id { get; set; } 9 | public string TaskType { get; set; } = null!; // invite/set_admin/create_channel等 10 | public string Status { get; set; } = "pending"; // pending/running/completed/failed 11 | public int Total { get; set; } 12 | public int Completed { get; set; } 13 | public int Failed { get; set; } 14 | public string? Config { get; set; } // JSON格式的任务配置 15 | public DateTime CreatedAt { get; set; } = DateTime.UtcNow; 16 | public DateTime? StartedAt { get; set; } 17 | public DateTime? CompletedAt { get; set; } 18 | } 19 | -------------------------------------------------------------------------------- /src/TelegramPanel.Core/Models/GroupInfo.cs: -------------------------------------------------------------------------------- 1 | namespace TelegramPanel.Core.Models; 2 | 3 | /// 4 | /// 群组信息 5 | /// 6 | public record GroupInfo 7 | { 8 | public int Id { get; init; } 9 | public long TelegramId { get; init; } 10 | public long AccessHash { get; init; } 11 | public string Title { get; init; } = string.Empty; 12 | public string? Username { get; init; } 13 | public int MemberCount { get; init; } 14 | public int CreatorAccountId { get; init; } 15 | public DateTime SyncedAt { get; init; } 16 | 17 | public bool IsPublic => !string.IsNullOrEmpty(Username); 18 | 19 | public string Link => IsPublic 20 | ? $"https://t.me/{Username}" 21 | : $"https://t.me/c/{TelegramId}"; 22 | } 23 | -------------------------------------------------------------------------------- /src/TelegramPanel.Web/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/launchsettings.json", 3 | "profiles": { 4 | "http": { 5 | "commandName": "Project", 6 | "dotnetRunMessages": true, 7 | "launchBrowser": true, 8 | "applicationUrl": "http://localhost:5000", 9 | "environmentVariables": { 10 | "ASPNETCORE_ENVIRONMENT": "Development" 11 | } 12 | }, 13 | "https": { 14 | "commandName": "Project", 15 | "dotnetRunMessages": true, 16 | "launchBrowser": true, 17 | "applicationUrl": "https://localhost:5001;http://localhost:5000", 18 | "environmentVariables": { 19 | "ASPNETCORE_ENVIRONMENT": "Development" 20 | } 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/TelegramPanel.Web/Components/App.razor: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/TelegramPanel.Data/Entities/AccountChannel.cs: -------------------------------------------------------------------------------- 1 | namespace TelegramPanel.Data.Entities; 2 | 3 | /// 4 | /// 账号-频道关联(用于记录“某账号是某频道的创建者/管理员”) 5 | /// 6 | public class AccountChannel 7 | { 8 | public int Id { get; set; } 9 | 10 | public int AccountId { get; set; } 11 | public int ChannelId { get; set; } 12 | 13 | /// 14 | /// 是否为创建者(拥有者) 15 | /// 16 | public bool IsCreator { get; set; } 17 | 18 | /// 19 | /// 是否为管理员(包含创建者) 20 | /// 21 | public bool IsAdmin { get; set; } 22 | 23 | public DateTime SyncedAt { get; set; } = DateTime.UtcNow; 24 | 25 | public Account Account { get; set; } = null!; 26 | public Channel Channel { get; set; } = null!; 27 | } 28 | 29 | -------------------------------------------------------------------------------- /src/TelegramPanel.Data/Entities/Bot.cs: -------------------------------------------------------------------------------- 1 | namespace TelegramPanel.Data.Entities; 2 | 3 | /// 4 | /// Telegram 机器人(Bot)实体 5 | /// 6 | public class Bot 7 | { 8 | public int Id { get; set; } 9 | public string Name { get; set; } = null!; 10 | public string Token { get; set; } = null!; 11 | public string? Username { get; set; } 12 | public bool IsActive { get; set; } = true; 13 | public DateTime CreatedAt { get; set; } = DateTime.UtcNow; 14 | public DateTime? LastSyncAt { get; set; } 15 | public long? LastUpdateId { get; set; } 16 | 17 | public ICollection Categories { get; set; } = new List(); 18 | public ICollection Channels { get; set; } = new List(); 19 | } 20 | -------------------------------------------------------------------------------- /src/TelegramPanel.Web/_Imports.razor: -------------------------------------------------------------------------------- 1 | @using System.Net.Http 2 | @using System.Net.Http.Json 3 | @using Microsoft.AspNetCore.Components 4 | @using Microsoft.AspNetCore.Components.Forms 5 | @using Microsoft.AspNetCore.Components.Routing 6 | @using Microsoft.AspNetCore.Components.Web 7 | @using static Microsoft.AspNetCore.Components.Web.RenderMode 8 | @using Microsoft.AspNetCore.Components.Web.Virtualization 9 | @using Microsoft.JSInterop 10 | @using MudBlazor 11 | @using MudBlazor.Utilities 12 | @using TelegramPanel.Core.Interfaces 13 | @using TelegramPanel.Core.Services 14 | @using TelegramPanel.Core.Services.Telegram 15 | @using TelegramPanel.Web.Components 16 | @using TelegramPanel.Web.Components.Shared 17 | @using TelegramPanel.Web.Components.Layout 18 | @using TelegramPanel.Web.Services 19 | -------------------------------------------------------------------------------- /src/TelegramPanel.Web/wwwroot/app.js: -------------------------------------------------------------------------------- 1 | window.telegramPanel = window.telegramPanel || {}; 2 | 3 | window.telegramPanel.copyText = async (text) => { 4 | if (text === null || text === undefined) return; 5 | const value = String(text); 6 | 7 | if (navigator && navigator.clipboard && navigator.clipboard.writeText) { 8 | await navigator.clipboard.writeText(value); 9 | return; 10 | } 11 | 12 | const textarea = document.createElement("textarea"); 13 | textarea.value = value; 14 | textarea.setAttribute("readonly", "true"); 15 | textarea.style.position = "fixed"; 16 | textarea.style.left = "-9999px"; 17 | textarea.style.top = "-9999px"; 18 | document.body.appendChild(textarea); 19 | textarea.select(); 20 | document.execCommand("copy"); 21 | document.body.removeChild(textarea); 22 | }; 23 | 24 | -------------------------------------------------------------------------------- /src/TelegramPanel.Data/TelegramPanel.Data.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | TelegramPanel.Data 8 | 9 | 10 | 11 | 12 | 13 | 14 | all 15 | runtime; build; native; contentfiles; analyzers; buildtransitive 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/TelegramPanel.Core/Interfaces/IGroupService.cs: -------------------------------------------------------------------------------- 1 | using TelegramPanel.Core.Models; 2 | 3 | namespace TelegramPanel.Core.Interfaces; 4 | 5 | /// 6 | /// 群组服务接口 7 | /// 8 | public interface IGroupService 9 | { 10 | /// 11 | /// 获取账号创建的所有群组 12 | /// 13 | Task> GetOwnedGroupsAsync(int accountId); 14 | 15 | /// 16 | /// 获取群组详情 17 | /// 18 | Task GetGroupInfoAsync(int accountId, long groupId); 19 | 20 | /// 21 | /// 导出加入链接:公开群组返回 t.me 链接;否则导出邀请链接。 22 | /// 23 | Task ExportJoinLinkAsync(int accountId, long groupId); 24 | 25 | /// 26 | /// 获取群组管理员列表(需要权限) 27 | /// 28 | Task> GetAdminsAsync(int accountId, long groupId); 29 | } 30 | -------------------------------------------------------------------------------- /src/TelegramPanel.Web/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "ConnectionStrings": { 3 | "DefaultConnection": "Data Source=telegram_panel.db" 4 | }, 5 | "Telegram": { 6 | "ApiId": 0, 7 | "ApiHash": "", 8 | "SessionsPath": "sessions", 9 | "DefaultDelayMs": 2000, 10 | "MaxRetries": 3 11 | }, 12 | "AdminAuth": { 13 | "Enabled": true, 14 | "InitialUsername": "admin", 15 | "InitialPassword": "admin123", 16 | "CredentialsPath": "admin_auth.json" 17 | }, 18 | "Hangfire": { 19 | "DashboardPath": "/hangfire" 20 | }, 21 | "Serilog": { 22 | "MinimumLevel": { 23 | "Default": "Information", 24 | "Override": { 25 | "Microsoft": "Warning", 26 | "Microsoft.Hosting.Lifetime": "Information", 27 | "System": "Warning" 28 | } 29 | } 30 | }, 31 | "AllowedHosts": "*" 32 | } 33 | -------------------------------------------------------------------------------- /src/TelegramPanel.Data/Entities/BotChannel.cs: -------------------------------------------------------------------------------- 1 | namespace TelegramPanel.Data.Entities; 2 | 3 | /// 4 | /// Bot 加入的频道(用于管理“非创建者频道”) 5 | /// 6 | public class BotChannel 7 | { 8 | public int Id { get; set; } 9 | public int BotId { get; set; } 10 | public long TelegramId { get; set; } 11 | public long? AccessHash { get; set; } 12 | public string Title { get; set; } = null!; 13 | public string? Username { get; set; } 14 | public bool IsBroadcast { get; set; } 15 | public int MemberCount { get; set; } 16 | public string? About { get; set; } 17 | public DateTime? CreatedAt { get; set; } 18 | public DateTime SyncedAt { get; set; } = DateTime.UtcNow; 19 | 20 | public int? CategoryId { get; set; } 21 | 22 | public Bot? Bot { get; set; } 23 | public BotChannelCategory? Category { get; set; } 24 | } 25 | 26 | -------------------------------------------------------------------------------- /src/TelegramPanel.Web/Components/_Imports.razor: -------------------------------------------------------------------------------- 1 | @using System.Net.Http 2 | @using System.Net.Http.Json 3 | @using Microsoft.AspNetCore.Hosting 4 | @using Microsoft.AspNetCore.Components 5 | @using Microsoft.AspNetCore.Components.Forms 6 | @using Microsoft.AspNetCore.Components.Routing 7 | @using Microsoft.AspNetCore.Components.Web 8 | @using static Microsoft.AspNetCore.Components.Web.RenderMode 9 | @using Microsoft.AspNetCore.Components.Web.Virtualization 10 | @using Microsoft.JSInterop 11 | @using MudBlazor 12 | @using MudBlazor.Utilities 13 | @using TelegramPanel.Core.Interfaces 14 | @using TelegramPanel.Core.Services 15 | @using TelegramPanel.Core.Services.Telegram 16 | @using TelegramPanel.Core.Models 17 | @using TelegramPanel.Web.Components 18 | @using TelegramPanel.Web.Components.Shared 19 | @using TelegramPanel.Web.Components.Layout 20 | @using TelegramPanel.Web.Services 21 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build 2 | WORKDIR /src 3 | 4 | COPY . . 5 | 6 | RUN dotnet restore "TelegramPanel.sln" 7 | RUN dotnet publish "src/TelegramPanel.Web/TelegramPanel.Web.csproj" -c Release -o /app/publish --no-restore 8 | 9 | FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final 10 | WORKDIR /app 11 | 12 | ENV ASPNETCORE_URLS=http://+:5000 13 | EXPOSE 5000 14 | 15 | # 持久化目录:/data(通过 docker-compose 挂载) 16 | # - 数据库:/data/telegram-panel.db 17 | # - session:/data/sessions/ 18 | # - 本地配置:/data/appsettings.local.json(UI 保存 Telegram ApiId/ApiHash/同步开关等) 19 | # - 后台密码:/data/admin_auth.json 20 | RUN mkdir -p /data /data/sessions /data/logs \ 21 | && rm -rf /app/logs \ 22 | && ln -s /data/logs /app/logs \ 23 | && ln -s /data/appsettings.local.json /app/appsettings.local.json || true 24 | 25 | COPY --from=build /app/publish . 26 | 27 | ENTRYPOINT ["dotnet", "TelegramPanel.Web.dll"] 28 | 29 | -------------------------------------------------------------------------------- /src/TelegramPanel.Core/Models/TelegramAuthorizationInfo.cs: -------------------------------------------------------------------------------- 1 | namespace TelegramPanel.Core.Models; 2 | 3 | /// 4 | /// 在线设备 / 会话信息(来自 account.getAuthorizations) 5 | /// 6 | public record TelegramAuthorizationInfo( 7 | long Hash, 8 | bool Current, 9 | int ApiId, 10 | string? AppName, 11 | string? AppVersion, 12 | string? DeviceModel, 13 | string? Platform, 14 | string? SystemVersion, 15 | string? Ip, 16 | string? Country, 17 | string? Region, 18 | DateTime? CreatedAtUtc, 19 | DateTime? LastActiveAtUtc 20 | ) 21 | { 22 | public string Title 23 | { 24 | get 25 | { 26 | var app = string.IsNullOrWhiteSpace(AppName) ? "UnknownApp" : AppName; 27 | var device = string.IsNullOrWhiteSpace(DeviceModel) ? "UnknownDevice" : DeviceModel; 28 | return $"{app} - {device}"; 29 | } 30 | } 31 | } 32 | 33 | -------------------------------------------------------------------------------- /src/TelegramPanel.Data/Repositories/ChannelGroupRepository.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | using TelegramPanel.Data.Entities; 3 | 4 | namespace TelegramPanel.Data.Repositories; 5 | 6 | /// 7 | /// 频道分组仓储实现 8 | /// 9 | public class ChannelGroupRepository : Repository, IChannelGroupRepository 10 | { 11 | public ChannelGroupRepository(AppDbContext context) : base(context) 12 | { 13 | } 14 | 15 | public override async Task> GetAllAsync() 16 | { 17 | return await _dbSet 18 | .Include(g => g.Channels) 19 | .OrderBy(g => g.Name) 20 | .ToListAsync(); 21 | } 22 | 23 | public async Task GetByNameAsync(string name) 24 | { 25 | return await _dbSet 26 | .Include(g => g.Channels) 27 | .FirstOrDefaultAsync(g => g.Name == name); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/TelegramPanel.Data/Repositories/AccountCategoryRepository.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | using TelegramPanel.Data.Entities; 3 | 4 | namespace TelegramPanel.Data.Repositories; 5 | 6 | /// 7 | /// 账号分类仓储实现 8 | /// 9 | public class AccountCategoryRepository : Repository, IAccountCategoryRepository 10 | { 11 | public AccountCategoryRepository(AppDbContext context) : base(context) 12 | { 13 | } 14 | 15 | public override async Task> GetAllAsync() 16 | { 17 | return await _dbSet 18 | .Include(c => c.Accounts) 19 | .OrderBy(c => c.Name) 20 | .ToListAsync(); 21 | } 22 | 23 | public async Task GetByNameAsync(string name) 24 | { 25 | return await _dbSet 26 | .Include(c => c.Accounts) 27 | .FirstOrDefaultAsync(c => c.Name == name); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/TelegramPanel.Data/Entities/Channel.cs: -------------------------------------------------------------------------------- 1 | namespace TelegramPanel.Data.Entities; 2 | 3 | /// 4 | /// 频道实体 5 | /// 6 | public class Channel 7 | { 8 | public int Id { get; set; } 9 | public long TelegramId { get; set; } 10 | public long? AccessHash { get; set; } 11 | public string Title { get; set; } = null!; 12 | public string? Username { get; set; } 13 | public bool IsBroadcast { get; set; } 14 | public int MemberCount { get; set; } 15 | public string? About { get; set; } 16 | public int? CreatorAccountId { get; set; } 17 | public int? GroupId { get; set; } 18 | public DateTime? CreatedAt { get; set; } 19 | public DateTime SyncedAt { get; set; } = DateTime.UtcNow; 20 | 21 | // 导航属性 22 | public Account? CreatorAccount { get; set; } 23 | public ICollection AccountChannels { get; set; } = new List(); 24 | public ChannelGroup? Group { get; set; } 25 | } 26 | -------------------------------------------------------------------------------- /src/TelegramPanel.Web/ExternalApi/ExternalApiCatalog.cs: -------------------------------------------------------------------------------- 1 | namespace TelegramPanel.Web.ExternalApi; 2 | 3 | public static class ExternalApiCatalog 4 | { 5 | public static readonly ExternalApiTypeInfo Kick = new( 6 | Type: ExternalApiTypes.Kick, 7 | DisplayName: "踢人/封禁", 8 | Route: "/api/kick", 9 | ProviderModuleId: "builtin.kick-api"); 10 | 11 | public static IReadOnlyList All { get; } = new[] 12 | { 13 | Kick 14 | }; 15 | 16 | public static ExternalApiTypeInfo? TryGet(string? type) 17 | { 18 | type = (type ?? string.Empty).Trim(); 19 | if (type.Length == 0) 20 | return null; 21 | 22 | return All.FirstOrDefault(x => string.Equals(x.Type, type, StringComparison.OrdinalIgnoreCase)); 23 | } 24 | } 25 | 26 | public sealed record ExternalApiTypeInfo( 27 | string Type, 28 | string DisplayName, 29 | string Route, 30 | string ProviderModuleId); 31 | 32 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | telegram-panel: 3 | build: 4 | context: . 5 | dockerfile: ${TP_DOCKERFILE:-Dockerfile} 6 | container_name: telegram-panel 7 | restart: unless-stopped 8 | ports: 9 | - "5000:5000" 10 | volumes: 11 | - ./docker-data:/data 12 | environment: 13 | ASPNETCORE_URLS: "http://+:5000" 14 | DOTNET_ENVIRONMENT: "Production" 15 | 16 | # SQLite 数据库(持久化到 ./docker-data) 17 | ConnectionStrings__DefaultConnection: "Data Source=/data/telegram-panel.db" 18 | 19 | # Sessions(持久化到 ./docker-data/sessions) 20 | Telegram__SessionsPath: "/data/sessions" 21 | 22 | # 后台登录凭据文件(持久化到 ./docker-data/admin_auth.json) 23 | AdminAuth__CredentialsPath: "/data/admin_auth.json" 24 | 25 | # 默认关闭,避免频繁调用 Telegram API;可在系统设置里开启并保存到 /data/appsettings.local.json 26 | Sync__AutoSyncEnabled: "false" 27 | 28 | # 默认关闭 bot 轮询自动同步(需要时可手动同步) 29 | Telegram__BotAutoSyncEnabled: "false" 30 | -------------------------------------------------------------------------------- /src/TelegramPanel.Data/Migrations/20251219000002_AddBotLastUpdateId.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore.Infrastructure; 2 | using Microsoft.EntityFrameworkCore.Migrations; 3 | 4 | #nullable disable 5 | 6 | namespace TelegramPanel.Data.Migrations 7 | { 8 | [DbContext(typeof(AppDbContext))] 9 | [Migration("20251219000002_AddBotLastUpdateId")] 10 | public partial class AddBotLastUpdateId : Migration 11 | { 12 | protected override void Up(MigrationBuilder migrationBuilder) 13 | { 14 | migrationBuilder.AddColumn( 15 | name: "LastUpdateId", 16 | table: "Bots", 17 | type: "INTEGER", 18 | nullable: true); 19 | } 20 | 21 | protected override void Down(MigrationBuilder migrationBuilder) 22 | { 23 | migrationBuilder.DropColumn( 24 | name: "LastUpdateId", 25 | table: "Bots"); 26 | } 27 | } 28 | } 29 | 30 | -------------------------------------------------------------------------------- /src/TelegramPanel.Data/Repositories/BotRepository.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | using TelegramPanel.Data.Entities; 3 | 4 | namespace TelegramPanel.Data.Repositories; 5 | 6 | public class BotRepository : Repository, IBotRepository 7 | { 8 | public BotRepository(AppDbContext context) : base(context) 9 | { 10 | } 11 | 12 | public async Task GetByNameAsync(string name) 13 | { 14 | name = (name ?? string.Empty).Trim(); 15 | if (string.IsNullOrWhiteSpace(name)) 16 | return null; 17 | 18 | return await _dbSet.FirstOrDefaultAsync(x => x.Name == name); 19 | } 20 | 21 | public async Task> GetAllWithStatsAsync() 22 | { 23 | // 只用于列表显示统计信息,避免一次性加载全部频道详情 24 | return await _dbSet 25 | .Include(x => x.Channels) 26 | .Include(x => x.Categories) 27 | .OrderByDescending(x => x.CreatedAt) 28 | .ToListAsync(); 29 | } 30 | } 31 | 32 | -------------------------------------------------------------------------------- /src/TelegramPanel.Modules.Abstractions/ITelegramPanelModule.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Routing; 2 | using Microsoft.Extensions.DependencyInjection; 3 | 4 | namespace TelegramPanel.Modules; 5 | 6 | public interface ITelegramPanelModule 7 | { 8 | ModuleManifest Manifest { get; } 9 | 10 | /// 11 | /// 可选:模块注入自身服务(注意:启用/停用通常需要重启才能生效)。 12 | /// 13 | void ConfigureServices(IServiceCollection services, ModuleHostContext context); 14 | 15 | /// 16 | /// 可选:模块映射 API endpoints。 17 | /// 18 | void MapEndpoints(IEndpointRouteBuilder endpoints, ModuleHostContext context); 19 | } 20 | 21 | public sealed class ModuleHostContext 22 | { 23 | public ModuleHostContext(string hostVersion, string modulesRootPath) 24 | { 25 | HostVersion = hostVersion; 26 | ModulesRootPath = modulesRootPath; 27 | } 28 | 29 | public string HostVersion { get; } 30 | public string ModulesRootPath { get; } 31 | } 32 | 33 | -------------------------------------------------------------------------------- /src/TelegramPanel.Core/Models/AccountInfo.cs: -------------------------------------------------------------------------------- 1 | namespace TelegramPanel.Core.Models; 2 | 3 | /// 4 | /// 账号信息 5 | /// 6 | public record AccountInfo 7 | { 8 | public int Id { get; init; } 9 | public long TelegramUserId { get; init; } 10 | public string? Phone { get; init; } 11 | public string? Username { get; init; } 12 | public string? FirstName { get; init; } 13 | public string? LastName { get; init; } 14 | public string? PhotoPath { get; init; } 15 | public AccountStatus Status { get; init; } 16 | public DateTime? LastActiveAt { get; init; } 17 | 18 | public string DisplayName => string.IsNullOrEmpty(Username) 19 | ? $"{FirstName} {LastName}".Trim() 20 | : $"@{Username}"; 21 | } 22 | 23 | /// 24 | /// 账号状态枚举 25 | /// 26 | public enum AccountStatus 27 | { 28 | Active, // 正常 29 | Offline, // 离线 30 | Banned, // 被封禁 31 | Limited, // 受限 32 | NeedRelogin // 需要重新登录 33 | } 34 | -------------------------------------------------------------------------------- /src/TelegramPanel.Data/Repositories/BotChannelCategoryRepository.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | using TelegramPanel.Data.Entities; 3 | 4 | namespace TelegramPanel.Data.Repositories; 5 | 6 | public class BotChannelCategoryRepository : Repository, IBotChannelCategoryRepository 7 | { 8 | public BotChannelCategoryRepository(AppDbContext context) : base(context) 9 | { 10 | } 11 | 12 | public async Task> GetForBotAsync(int botId) 13 | { 14 | return await _dbSet 15 | .Where(x => x.BotId == botId) 16 | .OrderBy(x => x.Name) 17 | .ToListAsync(); 18 | } 19 | 20 | public async Task GetByNameAsync(int botId, string name) 21 | { 22 | name = (name ?? string.Empty).Trim(); 23 | if (string.IsNullOrWhiteSpace(name)) 24 | return null; 25 | 26 | return await _dbSet.FirstOrDefaultAsync(x => x.BotId == botId && x.Name == name); 27 | } 28 | } 29 | 30 | -------------------------------------------------------------------------------- /src/TelegramPanel.Web/Modules/ModuleRegistry.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Routing; 2 | using TelegramPanel.Modules; 3 | 4 | namespace TelegramPanel.Web.Modules; 5 | 6 | public sealed class ModuleRegistry 7 | { 8 | private readonly List _modules = new(); 9 | 10 | public IReadOnlyList Modules => _modules; 11 | 12 | public void Add(LoadedModule module) => _modules.Add(module); 13 | 14 | public void MapEndpoints(IEndpointRouteBuilder endpoints) 15 | { 16 | foreach (var m in _modules) 17 | { 18 | try 19 | { 20 | m.Instance.MapEndpoints(endpoints, m.Context); 21 | } 22 | catch 23 | { 24 | // 模块出错不应影响主站启动;日志在上层打 25 | } 26 | } 27 | } 28 | } 29 | 30 | public sealed record LoadedModule( 31 | string Id, 32 | string Version, 33 | bool BuiltIn, 34 | ITelegramPanelModule Instance, 35 | ModuleHostContext Context, 36 | ModuleManifest Manifest, 37 | string? ModuleRootPath); 38 | -------------------------------------------------------------------------------- /Dockerfile.local: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build 2 | WORKDIR /src 3 | 4 | COPY . . 5 | 6 | RUN dotnet restore "TelegramPanel.sln" 7 | RUN dotnet publish "src/TelegramPanel.Web/TelegramPanel.Web.csproj" -c Release -o /app/publish --no-restore 8 | 9 | # 说明: 10 | # - 正常生产部署建议使用 mcr.microsoft.com/dotnet/aspnet:8.0 作为运行时镜像(更小) 11 | # - 若当前网络环境导致 Docker/WSL 无法拉取 aspnet 镜像,可临时使用本文件(运行时使用 sdk 镜像) 12 | FROM mcr.microsoft.com/dotnet/sdk:8.0 AS final 13 | WORKDIR /app 14 | 15 | ENV ASPNETCORE_URLS=http://+:5000 16 | EXPOSE 5000 17 | 18 | # 持久化目录:/data(通过 docker-compose 挂载) 19 | # - 数据库:/data/telegram-panel.db 20 | # - session:/data/sessions/ 21 | # - 本地配置:/data/appsettings.local.json(UI 保存 Telegram ApiId/ApiHash/同步开关等) 22 | # - 后台密码:/data/admin_auth.json 23 | RUN mkdir -p /data /data/sessions /data/logs \ 24 | && rm -rf /app/logs \ 25 | && ln -s /data/logs /app/logs \ 26 | && ln -s /data/appsettings.local.json /app/appsettings.local.json || true 27 | 28 | COPY --from=build /app/publish . 29 | 30 | ENTRYPOINT ["dotnet", "TelegramPanel.Web.dll"] 31 | 32 | -------------------------------------------------------------------------------- /docs/advanced.md: -------------------------------------------------------------------------------- 1 | # 进阶说明 2 | 3 | ## 技术栈 4 | 5 | - .NET 8 / ASP.NET Core / Blazor Server 6 | - MudBlazor 7 | - EF Core(默认 SQLite) 8 | - WTelegramClient(MTProto) 9 | 10 | ## Docker 数据目录(强相关) 11 | 12 | `docker-compose.yml` 会把宿主机 `./docker-data` 挂载到容器 `/data`,核心文件包括: 13 | 14 | - `/data/telegram-panel.db`:SQLite 数据库 15 | - `/data/sessions/`:账号 session 文件 16 | - `/data/appsettings.local.json`:UI 保存后的本地覆盖配置 17 | - `/data/admin_auth.json`:后台登录账号/密码(首次会用初始默认值生成) 18 | 19 | ## 后台任务(刷新页面不影响) 20 | 21 | 部分批量任务会在后台静默执行(避免“刷新页面就中断”): 22 | 23 | - 批量邀请 24 | - 批量设置管理员 25 | 26 | ## 账号状态检测(深度探测) 27 | 28 | 为更可靠识别冻结/受限等状态,支持深度探测(例如通过创建/删除测试频道来探测权限)。 29 | 30 | 检测结果会持久化到数据库,避免刷新页面又变回“未检测”。 31 | 32 | ## 配置项速查 33 | 34 | Docker 下常用环境变量(见 `docker-compose.yml`): 35 | 36 | - `ConnectionStrings__DefaultConnection`:SQLite 路径(默认 `/data/telegram-panel.db`) 37 | - `Telegram__SessionsPath`:session 目录(默认 `/data/sessions`) 38 | - `AdminAuth__CredentialsPath`:后台密码文件(默认 `/data/admin_auth.json`) 39 | - `Sync__AutoSyncEnabled`:账号创建的频道/群组自动同步(默认关闭) 40 | - `Telegram__BotAutoSyncEnabled`:Bot 频道轮询自动同步(默认关闭) 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # .NET 2 | bin/ 3 | obj/ 4 | *.dll 5 | *.exe 6 | *.pdb 7 | 8 | # IDE 9 | .vs/ 10 | .vscode/ 11 | *.user 12 | *.suo 13 | 14 | # Serena 15 | .serena/ 16 | 17 | # Local temp 18 | .tmp/ 19 | 20 | # Logs 21 | logs/ 22 | *.log 23 | 24 | # Local config overrides (secrets) 25 | appsettings.local.json 26 | src/TelegramPanel.Web/appsettings.local.json 27 | 28 | # Admin auth credentials 29 | src/TelegramPanel.Web/admin_auth.json 30 | 31 | # Sessions 32 | sessions/ 33 | session数据/ 34 | 35 | # Imported account packages (avoid committing phone/session dumps) 36 | */*.session 37 | */*.json 38 | 39 | # OS 40 | .DS_Store 41 | Thumbs.db 42 | 43 | # SQLite 44 | *.db 45 | 46 | # NuGet 47 | *.nupkg 48 | packages/ 49 | 50 | # Build results 51 | [Dd]ebug/ 52 | [Rr]elease/ 53 | x64/ 54 | x86/ 55 | 56 | # User-specific files 57 | *.rsuser 58 | *.userosscache 59 | *.sln.docstates 60 | 61 | # Local scratch 62 | todolist 63 | 64 | # Docker persistent data 65 | docker-data/ 66 | 67 | # Local build artifacts 68 | artifacts/ 69 | 模块源码/ 70 | src/TelegramPanel.Web/data-protection-keys/ 71 | -------------------------------------------------------------------------------- /src/TelegramPanel.Data/Migrations/20251220000000_AddLastLoginAt.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.EntityFrameworkCore.Infrastructure; 3 | using Microsoft.EntityFrameworkCore.Migrations; 4 | 5 | #nullable disable 6 | 7 | namespace TelegramPanel.Data.Migrations 8 | { 9 | [DbContext(typeof(AppDbContext))] 10 | [Migration("20251220000000_AddLastLoginAt")] 11 | /// 12 | public partial class AddLastLoginAt : Migration 13 | { 14 | /// 15 | protected override void Up(MigrationBuilder migrationBuilder) 16 | { 17 | migrationBuilder.AddColumn( 18 | name: "LastLoginAt", 19 | table: "Accounts", 20 | type: "TEXT", 21 | nullable: true); 22 | } 23 | 24 | /// 25 | protected override void Down(MigrationBuilder migrationBuilder) 26 | { 27 | migrationBuilder.DropColumn( 28 | name: "LastLoginAt", 29 | table: "Accounts"); 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/TelegramPanel.Data/Repositories/BotChannelRepository.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | using TelegramPanel.Data.Entities; 3 | 4 | namespace TelegramPanel.Data.Repositories; 5 | 6 | public class BotChannelRepository : Repository, IBotChannelRepository 7 | { 8 | public BotChannelRepository(AppDbContext context) : base(context) 9 | { 10 | } 11 | 12 | public async Task GetByTelegramIdAsync(int botId, long telegramId) 13 | { 14 | return await _dbSet 15 | .Include(x => x.Category) 16 | .FirstOrDefaultAsync(x => x.BotId == botId && x.TelegramId == telegramId); 17 | } 18 | 19 | public async Task> GetForBotAsync(int botId, int? categoryId = null) 20 | { 21 | var query = _dbSet 22 | .Include(x => x.Category) 23 | .Where(x => x.BotId == botId); 24 | 25 | if (categoryId.HasValue) 26 | query = query.Where(x => x.CategoryId == categoryId.Value); 27 | 28 | return await query 29 | .OrderByDescending(x => x.SyncedAt) 30 | .ToListAsync(); 31 | } 32 | } 33 | 34 | -------------------------------------------------------------------------------- /src/TelegramPanel.Web/ExternalApi/ExternalApiModels.cs: -------------------------------------------------------------------------------- 1 | namespace TelegramPanel.Web.ExternalApi; 2 | 3 | using System.Text.Json.Nodes; 4 | 5 | public static class ExternalApiTypes 6 | { 7 | public const string Kick = "kick"; 8 | } 9 | 10 | public sealed class ExternalApiDefinition 11 | { 12 | public string Id { get; set; } = Guid.NewGuid().ToString("N"); 13 | public string Name { get; set; } = ""; 14 | public string Type { get; set; } = ExternalApiTypes.Kick; 15 | public bool Enabled { get; set; } 16 | public string ApiKey { get; set; } = ""; 17 | 18 | /// 19 | /// 模块自定义配置(JSON object)。由具体 API 类型自行解释。 20 | /// 21 | public JsonObject Config { get; set; } = new(); 22 | 23 | /// 24 | /// 兼容内置 kick 的强类型配置(建议同时写入 Config)。 25 | /// 26 | public KickApiDefinition? Kick { get; set; } = new(); 27 | } 28 | 29 | public sealed class KickApiDefinition 30 | { 31 | public int BotId { get; set; } // 0=all bots 32 | public bool UseAllChats { get; set; } = true; 33 | public List ChatIds { get; set; } = new(); 34 | public bool PermanentBanDefault { get; set; } 35 | } 36 | 37 | -------------------------------------------------------------------------------- /src/TelegramPanel.Web/TelegramPanel.Web.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | TelegramPanel.Web 8 | 9 | true 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /src/TelegramPanel.Core/TelegramPanel.Core.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | TelegramPanel.Core 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /src/TelegramPanel.Web/Modules/BuiltIn/BuiltInModuleCatalog.cs: -------------------------------------------------------------------------------- 1 | using TelegramPanel.Modules; 2 | 3 | namespace TelegramPanel.Web.Modules.BuiltIn; 4 | 5 | public sealed class BuiltInModuleCatalog 6 | { 7 | private readonly List _modules; 8 | private readonly Dictionary _manifestById; 9 | 10 | public BuiltInModuleCatalog(string hostVersion) 11 | { 12 | _modules = new List 13 | { 14 | new KickApiModule(hostVersion), 15 | new TaskCatalogModule(hostVersion), 16 | }; 17 | 18 | _manifestById = _modules 19 | .Select(m => m.Manifest) 20 | .ToDictionary(m => m.Id, m => m, StringComparer.Ordinal); 21 | } 22 | 23 | public IReadOnlyList CreateModules() => _modules; 24 | 25 | public bool TryGetManifest(string id, out ModuleManifest manifest) 26 | { 27 | id = (id ?? string.Empty).Trim(); 28 | if (id.Length == 0) 29 | { 30 | manifest = new ModuleManifest(); 31 | return false; 32 | } 33 | 34 | return _manifestById.TryGetValue(id, out manifest!); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/TelegramPanel.Core/Interfaces/ITelegramClientPool.cs: -------------------------------------------------------------------------------- 1 | using WTelegram; 2 | 3 | namespace TelegramPanel.Core.Interfaces; 4 | 5 | /// 6 | /// Telegram客户端池接口 7 | /// 管理多个Telegram账号的客户端实例 8 | /// 9 | public interface ITelegramClientPool 10 | { 11 | /// 12 | /// 获取或创建指定账号的客户端 13 | /// 14 | Task GetOrCreateClientAsync( 15 | int accountId, 16 | int apiId, 17 | string apiHash, 18 | string sessionPath, 19 | string? sessionKey = null, 20 | string? phoneNumber = null, 21 | long? userId = null); 22 | 23 | /// 24 | /// 获取已存在的客户端 25 | /// 26 | Client? GetClient(int accountId); 27 | 28 | /// 29 | /// 移除并断开客户端连接 30 | /// 31 | Task RemoveClientAsync(int accountId); 32 | 33 | /// 34 | /// 移除并断开所有客户端连接(用于配置变更后强制重建) 35 | /// 36 | Task RemoveAllClientsAsync(); 37 | 38 | /// 39 | /// 获取所有活跃的客户端数量 40 | /// 41 | int ActiveClientCount { get; } 42 | 43 | /// 44 | /// 检查客户端是否已连接 45 | /// 46 | bool IsClientConnected(int accountId); 47 | } 48 | -------------------------------------------------------------------------------- /src/TelegramPanel.Data/Repositories/BatchTaskRepository.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | using TelegramPanel.Data.Entities; 3 | 4 | namespace TelegramPanel.Data.Repositories; 5 | 6 | /// 7 | /// 批量任务仓储实现 8 | /// 9 | public class BatchTaskRepository : Repository, IBatchTaskRepository 10 | { 11 | public BatchTaskRepository(AppDbContext context) : base(context) 12 | { 13 | } 14 | 15 | public async Task> GetByStatusAsync(string status) 16 | { 17 | return await _dbSet 18 | .Where(t => t.Status == status) 19 | .OrderByDescending(t => t.CreatedAt) 20 | .ToListAsync(); 21 | } 22 | 23 | public async Task> GetRunningTasksAsync() 24 | { 25 | return await _dbSet 26 | .Where(t => t.Status == "running") 27 | .OrderBy(t => t.StartedAt) 28 | .ToListAsync(); 29 | } 30 | 31 | public async Task> GetRecentTasksAsync(int count = 20) 32 | { 33 | return await _dbSet 34 | .OrderByDescending(t => t.CreatedAt) 35 | .Take(count) 36 | .ToListAsync(); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /docs/reverse-proxy.md: -------------------------------------------------------------------------------- 1 | # 反向代理(Nginx/Caddy) 2 | 3 | Telegram Panel 是 **Blazor Server**,需要 WebSocket(`/_blazor`)。 4 | 5 | 如果你反代后出现页面卡住/断开/一直重连,九成是 WebSocket 没配对。 6 | 7 | 另外,如果你遇到「打开页面被跳到 `http://localhost/login?ReturnUrl=%2F`」这种情况,说明反代没有把正确的 `Host`/`Proto` 透传给上游应用。 8 | 9 | ## Nginx(HTTP)示例 10 | 11 | 注意:请确保你的上游是 `http://127.0.0.1:5000`(对应 `docker-compose.yml` 暴露端口)。 12 | 13 | ```nginx 14 | server { 15 | listen 80; 16 | server_name example.com; 17 | 18 | location / { 19 | proxy_pass http://127.0.0.1:5000; 20 | proxy_http_version 1.1; 21 | proxy_set_header Upgrade $http_upgrade; 22 | proxy_set_header Connection "Upgrade"; 23 | proxy_set_header Host $host; 24 | proxy_set_header X-Forwarded-Host $host; 25 | proxy_set_header X-Forwarded-Proto $scheme; 26 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 27 | 28 | proxy_read_timeout 3600; 29 | proxy_send_timeout 3600; 30 | } 31 | } 32 | ``` 33 | 34 | ## Caddy 示例 35 | 36 | ```caddy 37 | example.com { 38 | reverse_proxy 127.0.0.1:5000 39 | } 40 | ``` 41 | 42 | 如果你使用了 CDN/面板(例如 Cloudflare),也要确认它对 WebSocket 的支持与超时设置。 43 | 44 | ## 宝塔面板(BT)反向代理要点 45 | 46 | - 反代目标:`http://127.0.0.1:5000` 47 | - 需要透传:`Host` + `X-Forwarded-Host` + `X-Forwarded-Proto`(否则可能跳到 `localhost`) 48 | -------------------------------------------------------------------------------- /src/TelegramPanel.Data/Migrations/20251219000004_AccountUserIdIndexNotUnique.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore.Infrastructure; 2 | using Microsoft.EntityFrameworkCore.Migrations; 3 | 4 | #nullable disable 5 | 6 | namespace TelegramPanel.Data.Migrations 7 | { 8 | [DbContext(typeof(AppDbContext))] 9 | [Migration("20251219000004_AccountUserIdIndexNotUnique")] 10 | public partial class AccountUserIdIndexNotUnique : Migration 11 | { 12 | protected override void Up(MigrationBuilder migrationBuilder) 13 | { 14 | // 兼容:某些历史库可能没有该索引(例如曾用 EnsureCreated 创建过不同 schema) 15 | migrationBuilder.Sql("DROP INDEX IF EXISTS IX_Accounts_UserId;"); 16 | 17 | migrationBuilder.CreateIndex( 18 | name: "IX_Accounts_UserId", 19 | table: "Accounts", 20 | column: "UserId"); 21 | } 22 | 23 | protected override void Down(MigrationBuilder migrationBuilder) 24 | { 25 | migrationBuilder.Sql("DROP INDEX IF EXISTS IX_Accounts_UserId;"); 26 | 27 | migrationBuilder.CreateIndex( 28 | name: "IX_Accounts_UserId", 29 | table: "Accounts", 30 | column: "UserId", 31 | unique: true); 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/TelegramPanel.Core/Interfaces/ISessionImporter.cs: -------------------------------------------------------------------------------- 1 | using TelegramPanel.Core.Models; 2 | 3 | namespace TelegramPanel.Core.Interfaces; 4 | 5 | /// 6 | /// Session导入服务接口 7 | /// 8 | public interface ISessionImporter 9 | { 10 | /// 11 | /// 从Session文件导入 12 | /// 13 | Task ImportFromSessionFileAsync( 14 | string filePath, 15 | int apiId, 16 | string apiHash, 17 | long? userId = null, 18 | string? phoneHint = null); 19 | 20 | /// 21 | /// 批量导入Session文件 22 | /// 23 | Task> BatchImportSessionFilesAsync( 24 | string[] filePaths, 25 | int apiId, 26 | string apiHash); 27 | 28 | /// 29 | /// 从StringSession导入 30 | /// 31 | Task ImportFromStringSessionAsync(string sessionString, int apiId, string apiHash); 32 | 33 | /// 34 | /// 验证Session是否有效 35 | /// 36 | Task ValidateSessionAsync(string sessionPath); 37 | } 38 | 39 | /// 40 | /// 导入结果 41 | /// 42 | public record ImportResult( 43 | bool Success, 44 | string? Phone, 45 | long? UserId, 46 | string? Username, 47 | string? SessionPath, 48 | string? Error = null 49 | ); 50 | -------------------------------------------------------------------------------- /src/TelegramPanel.Web/Services/AppRestartService.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Hosting; 2 | using Microsoft.Extensions.Logging; 3 | 4 | namespace TelegramPanel.Web.Services; 5 | 6 | public sealed class AppRestartService 7 | { 8 | private readonly IHostApplicationLifetime _lifetime; 9 | private readonly ILogger _logger; 10 | private int _requested; 11 | 12 | public AppRestartService(IHostApplicationLifetime lifetime, ILogger logger) 13 | { 14 | _lifetime = lifetime; 15 | _logger = logger; 16 | } 17 | 18 | public bool RestartPending => Volatile.Read(ref _requested) != 0; 19 | 20 | public void RequestRestart(TimeSpan? delay = null, string? reason = null) 21 | { 22 | if (Interlocked.Exchange(ref _requested, 1) != 0) 23 | return; 24 | 25 | delay ??= TimeSpan.FromSeconds(1); 26 | _logger.LogWarning("Restart requested: {Reason}. Stop in {DelayMs}ms", reason ?? "-", (int)delay.Value.TotalMilliseconds); 27 | 28 | _ = Task.Run(async () => 29 | { 30 | try 31 | { 32 | await Task.Delay(delay.Value); 33 | } 34 | catch 35 | { 36 | // ignore 37 | } 38 | 39 | _lifetime.StopApplication(); 40 | }); 41 | } 42 | } 43 | 44 | -------------------------------------------------------------------------------- /src/TelegramPanel.Data/Repositories/GroupRepository.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | using TelegramPanel.Data.Entities; 3 | 4 | namespace TelegramPanel.Data.Repositories; 5 | 6 | /// 7 | /// 群组仓储实现 8 | /// 9 | public class GroupRepository : Repository, IGroupRepository 10 | { 11 | public GroupRepository(AppDbContext context) : base(context) 12 | { 13 | } 14 | 15 | public override async Task GetByIdAsync(int id) 16 | { 17 | return await _dbSet 18 | .Include(g => g.CreatorAccount) 19 | .FirstOrDefaultAsync(g => g.Id == id); 20 | } 21 | 22 | public override async Task> GetAllAsync() 23 | { 24 | return await _dbSet 25 | .Include(g => g.CreatorAccount) 26 | .OrderByDescending(g => g.SyncedAt) 27 | .ToListAsync(); 28 | } 29 | 30 | public async Task GetByTelegramIdAsync(long telegramId) 31 | { 32 | return await _dbSet 33 | .Include(g => g.CreatorAccount) 34 | .FirstOrDefaultAsync(g => g.TelegramId == telegramId); 35 | } 36 | 37 | public async Task> GetByCreatorAccountAsync(int accountId) 38 | { 39 | return await _dbSet 40 | .Include(g => g.CreatorAccount) 41 | .Where(g => g.CreatorAccountId == accountId) 42 | .OrderByDescending(g => g.SyncedAt) 43 | .ToListAsync(); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/TelegramPanel.Core/Models/ChannelInfo.cs: -------------------------------------------------------------------------------- 1 | namespace TelegramPanel.Core.Models; 2 | 3 | /// 4 | /// 频道信息 5 | /// 6 | public record ChannelInfo 7 | { 8 | public int Id { get; init; } 9 | public long TelegramId { get; init; } 10 | public long AccessHash { get; init; } 11 | public string Title { get; init; } = string.Empty; 12 | public string? Username { get; init; } 13 | public bool IsPublic => !string.IsNullOrEmpty(Username); 14 | public bool IsBroadcast { get; init; } // true=频道, false=超级群组 15 | public int MemberCount { get; init; } 16 | public string? About { get; init; } 17 | /// 18 | /// 系统内“创建该频道”的账号(仅当本系统创建过该频道时才有值) 19 | /// 20 | public int? CreatorAccountId { get; init; } 21 | /// 22 | /// 对于当前 accountId:是否为频道创建者(拥有者) 23 | /// 24 | public bool IsCreator { get; init; } 25 | /// 26 | /// 对于当前 accountId:是否为管理员(包含创建者) 27 | /// 28 | public bool IsAdmin { get; init; } 29 | public int? GroupId { get; init; } 30 | public string? GroupName { get; init; } 31 | public DateTime? CreatedAt { get; init; } 32 | public DateTime SyncedAt { get; init; } 33 | 34 | /// 35 | /// 频道链接 36 | /// 37 | public string Link => IsPublic 38 | ? $"https://t.me/{Username}" 39 | : $"https://t.me/c/{TelegramId}"; 40 | 41 | /// 42 | /// 频道类型显示名称 43 | /// 44 | public string TypeName => IsBroadcast ? "频道" : "超级群组"; 45 | } 46 | -------------------------------------------------------------------------------- /src/TelegramPanel.Core/Models/BatchTaskInfo.cs: -------------------------------------------------------------------------------- 1 | namespace TelegramPanel.Core.Models; 2 | 3 | /// 4 | /// 批量任务信息 5 | /// 6 | public record BatchTaskInfo 7 | { 8 | public int Id { get; init; } 9 | public string Type { get; init; } = string.Empty; 10 | public string Name { get; init; } = string.Empty; 11 | public TaskStatus Status { get; init; } 12 | public int Progress { get; init; } 13 | public int Total { get; init; } 14 | public int SuccessCount { get; init; } 15 | public int FailedCount { get; init; } 16 | public string? ErrorMessage { get; init; } 17 | public DateTime CreatedAt { get; init; } 18 | public DateTime? StartedAt { get; init; } 19 | public DateTime? CompletedAt { get; init; } 20 | 21 | public double ProgressPercent => Total > 0 ? (double)Progress / Total * 100 : 0; 22 | public TimeSpan? Duration => CompletedAt.HasValue && StartedAt.HasValue 23 | ? CompletedAt.Value - StartedAt.Value 24 | : null; 25 | } 26 | 27 | /// 28 | /// 任务状态 29 | /// 30 | public enum TaskStatus 31 | { 32 | Pending, // 等待中 33 | Running, // 执行中 34 | Completed, // 已完成 35 | Failed, // 失败 36 | Cancelled // 已取消 37 | } 38 | 39 | /// 40 | /// 任务类型 41 | /// 42 | public static class TaskTypes 43 | { 44 | public const string InviteUsers = "invite_users"; 45 | public const string SetAdmins = "set_admins"; 46 | public const string CreateChannel = "create_channel"; 47 | public const string SyncData = "sync_data"; 48 | } 49 | -------------------------------------------------------------------------------- /src/TelegramPanel.Core/Services/ChannelGroupManagementService.cs: -------------------------------------------------------------------------------- 1 | using TelegramPanel.Data.Entities; 2 | using TelegramPanel.Data.Repositories; 3 | 4 | namespace TelegramPanel.Core.Services; 5 | 6 | /// 7 | /// 频道分组管理服务 8 | /// 9 | public class ChannelGroupManagementService 10 | { 11 | private readonly IChannelGroupRepository _groupRepository; 12 | 13 | public ChannelGroupManagementService(IChannelGroupRepository groupRepository) 14 | { 15 | _groupRepository = groupRepository; 16 | } 17 | 18 | public async Task> GetAllGroupsAsync() 19 | { 20 | return await _groupRepository.GetAllAsync(); 21 | } 22 | 23 | public async Task GetGroupAsync(int id) 24 | { 25 | return await _groupRepository.GetByIdAsync(id); 26 | } 27 | 28 | public async Task GetGroupByNameAsync(string name) 29 | { 30 | return await _groupRepository.GetByNameAsync(name); 31 | } 32 | 33 | public async Task CreateGroupAsync(ChannelGroup group) 34 | { 35 | return await _groupRepository.AddAsync(group); 36 | } 37 | 38 | public async Task UpdateGroupAsync(ChannelGroup group) 39 | { 40 | await _groupRepository.UpdateAsync(group); 41 | } 42 | 43 | public async Task DeleteGroupAsync(int id) 44 | { 45 | var group = await _groupRepository.GetByIdAsync(id); 46 | if (group != null) 47 | { 48 | await _groupRepository.DeleteAsync(group); 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/TelegramPanel.Web/Modules/BuiltIn/TaskCatalogModule.cs: -------------------------------------------------------------------------------- 1 | using TelegramPanel.Core.BatchTasks; 2 | using TelegramPanel.Modules; 3 | using MudBlazor; 4 | 5 | namespace TelegramPanel.Web.Modules.BuiltIn; 6 | 7 | public sealed class TaskCatalogModule : ITelegramPanelModule, IModuleTaskProvider 8 | { 9 | public TaskCatalogModule(string version) 10 | { 11 | Manifest = new ModuleManifest 12 | { 13 | Id = "builtin.tasks", 14 | Name = "任务:内置批量任务", 15 | Version = version, 16 | Host = new HostCompatibility(), 17 | Entry = new ModuleEntryPoint { Assembly = "", Type = typeof(TaskCatalogModule).FullName ?? "" } 18 | }; 19 | } 20 | 21 | public ModuleManifest Manifest { get; } 22 | 23 | public void ConfigureServices(IServiceCollection services, ModuleHostContext context) 24 | { 25 | // 内置任务的执行由宿主 BatchTaskBackgroundService 负责;这里只提供“元数据”用于 UI 展示与创建。 26 | } 27 | 28 | public void MapEndpoints(IEndpointRouteBuilder endpoints, ModuleHostContext context) 29 | { 30 | // 无 endpoints 31 | } 32 | 33 | public IEnumerable GetTasks(ModuleHostContext context) 34 | { 35 | yield return new ModuleTaskDefinition 36 | { 37 | Category = "system", 38 | TaskType = BatchTaskTypes.ExternalApiKick, 39 | DisplayName = "外部 API:踢人/封禁", 40 | Description = "由外部接口触发并记录到任务中心(一般无需手动创建)。", 41 | Icon = Icons.Material.Filled.Link, 42 | Order = 1000 43 | }; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/TelegramPanel.Modules.Abstractions/ModuleManifest.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | 3 | namespace TelegramPanel.Modules; 4 | 5 | public sealed class ModuleManifest 6 | { 7 | [JsonPropertyName("id")] 8 | public string Id { get; set; } = ""; 9 | 10 | [JsonPropertyName("name")] 11 | public string Name { get; set; } = ""; 12 | 13 | [JsonPropertyName("version")] 14 | public string Version { get; set; } = "0.0.0"; 15 | 16 | [JsonPropertyName("host")] 17 | public HostCompatibility Host { get; set; } = new(); 18 | 19 | [JsonPropertyName("dependencies")] 20 | public List Dependencies { get; set; } = new(); 21 | 22 | [JsonPropertyName("entry")] 23 | public ModuleEntryPoint Entry { get; set; } = new(); 24 | } 25 | 26 | public sealed class HostCompatibility 27 | { 28 | [JsonPropertyName("min")] 29 | public string? Min { get; set; } 30 | 31 | [JsonPropertyName("max")] 32 | public string? Max { get; set; } 33 | } 34 | 35 | public sealed class ModuleDependency 36 | { 37 | [JsonPropertyName("id")] 38 | public string Id { get; set; } = ""; 39 | 40 | /// 41 | /// 版本范围表达式,支持: 42 | /// - 1.2.3(等于) 43 | /// - >=1.2.3 44 | /// - >=1.2.3 <2.0.0(空格分隔多个条件) 45 | /// 46 | [JsonPropertyName("range")] 47 | public string Range { get; set; } = ""; 48 | } 49 | 50 | public sealed class ModuleEntryPoint 51 | { 52 | [JsonPropertyName("assembly")] 53 | public string Assembly { get; set; } = ""; 54 | 55 | [JsonPropertyName("type")] 56 | public string Type { get; set; } = ""; 57 | } 58 | 59 | -------------------------------------------------------------------------------- /src/TelegramPanel.Web/Components/Dialogs/ExternalApiCurlSampleDialog.razor: -------------------------------------------------------------------------------- 1 | @namespace TelegramPanel.Web.Components.Dialogs 2 | @inject ISnackbar Snackbar 3 | @inject IJSRuntime JS 4 | 5 | 6 | 7 | 8 | 9 | 该 URL 来自你当前访问面板的地址(包含真实域名/端口)。点击「复制」会复制完整命令(不会截断 X-API-Key)。 10 | 11 | 12 | 14 | 15 | 16 | 17 | 复制 18 | 关闭 19 | 20 | 21 | 22 | @code 23 | { 24 | [CascadingParameter] private MudDialogInstance MudDialog { get; set; } = default!; 25 | 26 | [Parameter] public string Curl { get; set; } = ""; 27 | 28 | private async Task Copy() 29 | { 30 | try 31 | { 32 | await JS.InvokeVoidAsync("telegramPanel.copyText", Curl); 33 | Snackbar.Add("已复制", Severity.Success); 34 | } 35 | catch (Exception ex) 36 | { 37 | Snackbar.Add($"复制失败:{ex.Message}", Severity.Error); 38 | } 39 | } 40 | 41 | private void Close() => MudDialog.Close(); 42 | } 43 | -------------------------------------------------------------------------------- /src/TelegramPanel.Web/Components/Dialogs/BotBatchSetCategoryDialog.razor: -------------------------------------------------------------------------------- 1 | @namespace TelegramPanel.Web.Components.Dialogs 2 | @using TelegramPanel.Data.Entities 3 | 4 | 5 | 6 | 7 | 8 | 将为 @SelectedCount 个频道设置分类 9 | 10 | 11 | 12 | 未分类 13 | @foreach (var category in Categories) 14 | { 15 | @category.Name 16 | } 17 | 18 | 19 | 20 | 21 | 取消 22 | 23 | 确定 24 | 25 | 26 | 27 | 28 | @code 29 | { 30 | [CascadingParameter] private MudDialogInstance MudDialog { get; set; } = default!; 31 | [Parameter] public List Categories { get; set; } = new(); 32 | [Parameter] public int SelectedCount { get; set; } 33 | 34 | private int selectedCategoryId = 0; 35 | 36 | private void Cancel() 37 | { 38 | MudDialog.Cancel(); 39 | } 40 | 41 | private void Confirm() 42 | { 43 | MudDialog.Close(DialogResult.Ok(selectedCategoryId)); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/TelegramPanel.Core/Services/AccountCategoryManagementService.cs: -------------------------------------------------------------------------------- 1 | using TelegramPanel.Data.Entities; 2 | using TelegramPanel.Data.Repositories; 3 | 4 | namespace TelegramPanel.Core.Services; 5 | 6 | /// 7 | /// 账号分类管理服务 8 | /// 9 | public class AccountCategoryManagementService 10 | { 11 | private readonly IAccountCategoryRepository _categoryRepository; 12 | 13 | public AccountCategoryManagementService(IAccountCategoryRepository categoryRepository) 14 | { 15 | _categoryRepository = categoryRepository; 16 | } 17 | 18 | public async Task> GetAllCategoriesAsync() 19 | { 20 | return await _categoryRepository.GetAllAsync(); 21 | } 22 | 23 | public async Task GetCategoryAsync(int id) 24 | { 25 | return await _categoryRepository.GetByIdAsync(id); 26 | } 27 | 28 | public async Task GetCategoryByNameAsync(string name) 29 | { 30 | return await _categoryRepository.GetByNameAsync(name); 31 | } 32 | 33 | public async Task CreateCategoryAsync(AccountCategory category) 34 | { 35 | return await _categoryRepository.AddAsync(category); 36 | } 37 | 38 | public async Task UpdateCategoryAsync(AccountCategory category) 39 | { 40 | await _categoryRepository.UpdateAsync(category); 41 | } 42 | 43 | public async Task DeleteCategoryAsync(int id) 44 | { 45 | var category = await _categoryRepository.GetByIdAsync(id); 46 | if (category != null) 47 | { 48 | await _categoryRepository.DeleteAsync(category); 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/TelegramPanel.Web/Components/Dialogs/BatchSetAccountCategoryDialog.razor: -------------------------------------------------------------------------------- 1 | @namespace TelegramPanel.Web.Components.Dialogs 2 | @using TelegramPanel.Data.Entities 3 | 4 | 5 | 6 | 7 | 8 | 将为 @SelectedCount 个账号设置分类 9 | 10 | 11 | 12 | 未分类 13 | @foreach (var category in Categories) 14 | { 15 | @category.Name 16 | } 17 | 18 | 19 | 20 | 21 | 取消 22 | 23 | 确定 24 | 25 | 26 | 27 | 28 | @code 29 | { 30 | [CascadingParameter] private MudDialogInstance MudDialog { get; set; } = default!; 31 | [Parameter] public List Categories { get; set; } = new(); 32 | [Parameter] public int SelectedCount { get; set; } 33 | 34 | private int selectedCategoryId = 0; 35 | 36 | private void Cancel() 37 | { 38 | MudDialog.Cancel(); 39 | } 40 | 41 | private void Confirm() 42 | { 43 | MudDialog.Close(DialogResult.Ok(selectedCategoryId)); 44 | } 45 | } 46 | 47 | -------------------------------------------------------------------------------- /src/TelegramPanel.Core/Models/TelegramAccountStatusResult.cs: -------------------------------------------------------------------------------- 1 | using TelegramPanel.Data.Entities; 2 | 3 | namespace TelegramPanel.Core.Models; 4 | 5 | /// 6 | /// 账号状态检测结果(用于诊断账号是否可正常连通 Telegram) 7 | /// 8 | public record TelegramAccountStatusResult( 9 | bool Ok, 10 | string Summary, 11 | string? Details, 12 | DateTime CheckedAtUtc, 13 | TelegramAccountProfile? Profile = null 14 | ); 15 | 16 | /// 17 | /// Telegram 账号资料快照 18 | /// 19 | public record TelegramAccountProfile( 20 | long UserId, 21 | string? Phone, 22 | string? Username, 23 | string? FirstName, 24 | string? LastName, 25 | bool IsDeleted, 26 | bool IsScam, 27 | bool IsFake, 28 | bool IsRestricted, 29 | bool IsVerified, 30 | bool IsPremium 31 | ) 32 | { 33 | public string DisplayName 34 | { 35 | get 36 | { 37 | var full = $"{FirstName} {LastName}".Trim(); 38 | if (!string.IsNullOrWhiteSpace(full)) 39 | return full; 40 | if (!string.IsNullOrWhiteSpace(Username)) 41 | return $"@{Username}"; 42 | if (!string.IsNullOrWhiteSpace(Phone)) 43 | return Phone; 44 | return UserId.ToString(); 45 | } 46 | } 47 | 48 | public void ApplyTo(Account account) 49 | { 50 | if (account.UserId <= 0 && UserId > 0) 51 | account.UserId = UserId; 52 | 53 | if (!string.IsNullOrWhiteSpace(Username)) 54 | account.Username = Username; 55 | 56 | var nickname = DisplayName; 57 | if (!string.IsNullOrWhiteSpace(nickname)) 58 | account.Nickname = nickname; 59 | } 60 | } 61 | 62 | -------------------------------------------------------------------------------- /src/TelegramPanel.Data/ServiceCollectionExtensions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | using Microsoft.Extensions.DependencyInjection; 3 | using TelegramPanel.Data.Repositories; 4 | 5 | namespace TelegramPanel.Data; 6 | 7 | /// 8 | /// Data层服务注册扩展 9 | /// 10 | public static class ServiceCollectionExtensions 11 | { 12 | public static IServiceCollection AddTelegramPanelData(this IServiceCollection services, string connectionString) 13 | { 14 | // 注册数据库上下文 15 | services.AddDbContext(options => 16 | options.UseSqlite(connectionString, sqlite => 17 | { 18 | // 避免 Include 多个集合导航时的“笛卡尔爆炸”与性能警告 19 | sqlite.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery); 20 | })); 21 | 22 | // 注册所有Repository 23 | services.AddScoped(); 24 | services.AddScoped(); 25 | services.AddScoped(); 26 | services.AddScoped(); 27 | services.AddScoped(); 28 | services.AddScoped(); 29 | services.AddScoped(); 30 | services.AddScoped(); 31 | services.AddScoped(); 32 | services.AddScoped(); 33 | services.AddScoped(typeof(IRepository<>), typeof(Repository<>)); 34 | 35 | return services; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/TelegramPanel.Core/Utils/PhoneNumberFormatter.cs: -------------------------------------------------------------------------------- 1 | using PhoneNumbers; 2 | 3 | namespace TelegramPanel.Core.Utils; 4 | 5 | /// 6 | /// 手机号格式化/规范化工具(用于展示国家码) 7 | /// 8 | public static class PhoneNumberFormatter 9 | { 10 | private static readonly PhoneNumberUtil Util = PhoneNumberUtil.GetInstance(); 11 | 12 | /// 13 | /// 仅保留数字(用于数据库存储/查询/文件名) 14 | /// 15 | public static string NormalizeToDigits(string? phone) 16 | { 17 | if (string.IsNullOrWhiteSpace(phone)) 18 | return string.Empty; 19 | 20 | var digits = new char[phone.Length]; 21 | var count = 0; 22 | foreach (var ch in phone) 23 | { 24 | if (ch >= '0' && ch <= '9') 25 | digits[count++] = ch; 26 | } 27 | 28 | return count == 0 ? string.Empty : new string(digits, 0, count); 29 | } 30 | 31 | /// 32 | /// 格式化为 “+国家码 空格 本地号码” 的展示形式,如:+86 13800138000 33 | /// 34 | public static string FormatWithCountryCode(string? phone) 35 | { 36 | var digits = NormalizeToDigits(phone); 37 | if (digits.Length == 0) 38 | return (phone ?? string.Empty).Trim(); 39 | 40 | try 41 | { 42 | // Telegram 账号手机号通常是 E.164 数字串(无 +),这里统一按 E.164 解析 43 | var number = Util.Parse("+" + digits, defaultRegion: null); 44 | var nsn = Util.GetNationalSignificantNumber(number); 45 | if (string.IsNullOrWhiteSpace(nsn)) 46 | return "+" + digits; 47 | 48 | return $"+{number.CountryCode} {nsn}"; 49 | } 50 | catch 51 | { 52 | // 解析失败也至少补上 "+" 53 | return "+" + digits; 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/TelegramPanel.Data/Repositories/Repository.cs: -------------------------------------------------------------------------------- 1 | using System.Linq.Expressions; 2 | using Microsoft.EntityFrameworkCore; 3 | 4 | namespace TelegramPanel.Data.Repositories; 5 | 6 | /// 7 | /// 通用仓储实现 8 | /// 9 | public class Repository : IRepository where T : class 10 | { 11 | protected readonly AppDbContext _context; 12 | protected readonly DbSet _dbSet; 13 | 14 | public Repository(AppDbContext context) 15 | { 16 | _context = context; 17 | _dbSet = context.Set(); 18 | } 19 | 20 | public virtual async Task GetByIdAsync(int id) 21 | { 22 | return await _dbSet.FindAsync(id); 23 | } 24 | 25 | public virtual async Task> GetAllAsync() 26 | { 27 | return await _dbSet.ToListAsync(); 28 | } 29 | 30 | public virtual async Task> FindAsync(Expression> predicate) 31 | { 32 | return await _dbSet.Where(predicate).ToListAsync(); 33 | } 34 | 35 | public virtual async Task AddAsync(T entity) 36 | { 37 | await _dbSet.AddAsync(entity); 38 | await _context.SaveChangesAsync(); 39 | return entity; 40 | } 41 | 42 | public virtual async Task UpdateAsync(T entity) 43 | { 44 | _dbSet.Update(entity); 45 | await _context.SaveChangesAsync(); 46 | } 47 | 48 | public virtual async Task DeleteAsync(T entity) 49 | { 50 | _dbSet.Remove(entity); 51 | await _context.SaveChangesAsync(); 52 | } 53 | 54 | public virtual async Task CountAsync(Expression>? predicate = null) 55 | { 56 | return predicate == null 57 | ? await _dbSet.CountAsync() 58 | : await _dbSet.CountAsync(predicate); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/TelegramPanel.Core/ServiceCollectionExtensions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.DependencyInjection; 2 | using TelegramPanel.Core.Interfaces; 3 | using TelegramPanel.Core.Services; 4 | using TelegramPanel.Core.Services.Telegram; 5 | 6 | namespace TelegramPanel.Core; 7 | 8 | /// 9 | /// Core层服务注册扩展 10 | /// 11 | public static class ServiceCollectionExtensions 12 | { 13 | public static IServiceCollection AddTelegramPanelCore(this IServiceCollection services) 14 | { 15 | // 注册 Telegram 客户端池(单例) 16 | services.AddSingleton(); 17 | 18 | // 注册 Telegram 操作服务 19 | services.AddScoped(); 20 | services.AddScoped(); 21 | services.AddScoped(); 22 | services.AddScoped(); 23 | services.AddScoped(); 24 | services.AddScoped(); 25 | 26 | // Bot API updates(getUpdates)统一轮询与分发:避免 409 Conflict 27 | services.AddSingleton(); 28 | 29 | // 注册账号导入协调服务 30 | services.AddScoped(); 31 | 32 | // 注册数据管理服务 33 | services.AddScoped(); 34 | services.AddScoped(); 35 | services.AddScoped(); 36 | services.AddScoped(); 37 | services.AddScoped(); 38 | services.AddScoped(); 39 | services.AddScoped(); 40 | 41 | // 注册风控服务 42 | services.AddScoped(); 43 | 44 | return services; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/TelegramPanel.Web/Modules/BuiltIn/KickApiModule.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Builder; 2 | using Microsoft.AspNetCore.Routing; 3 | using Microsoft.Extensions.Configuration; 4 | using Microsoft.Extensions.DependencyInjection; 5 | using TelegramPanel.Modules; 6 | using TelegramPanel.Web.ExternalApi; 7 | 8 | namespace TelegramPanel.Web.Modules.BuiltIn; 9 | 10 | public sealed class KickApiModule : ITelegramPanelModule, IModuleApiProvider 11 | { 12 | public KickApiModule(string version) 13 | { 14 | Manifest = new ModuleManifest 15 | { 16 | Id = "builtin.kick-api", 17 | Name = "外部 API:踢人/封禁", 18 | Version = version, 19 | Host = new HostCompatibility(), 20 | Entry = new ModuleEntryPoint { Assembly = "", Type = typeof(KickApiModule).FullName ?? "" } 21 | }; 22 | } 23 | 24 | public ModuleManifest Manifest { get; } 25 | 26 | public void ConfigureServices(IServiceCollection services, ModuleHostContext context) 27 | { 28 | // built-in:无需额外注入 29 | } 30 | 31 | public void MapEndpoints(IEndpointRouteBuilder endpoints, ModuleHostContext context) 32 | { 33 | if (!endpoints.ServiceProvider.GetRequiredService().GetValue("ExternalApi:Enabled", true)) 34 | { 35 | // 预留开关;默认开启 36 | } 37 | 38 | endpoints.MapKickApi(); 39 | } 40 | 41 | public IEnumerable GetApis(ModuleHostContext context) 42 | { 43 | yield return new ModuleApiTypeDefinition 44 | { 45 | Type = ExternalApiTypes.Kick, 46 | DisplayName = "踢人/封禁", 47 | Route = "/api/kick", 48 | Description = "从配置的 Bot 管理的频道/群组中踢出或封禁指定用户(按 X-API-Key 匹配配置项)。", 49 | Order = 10 50 | }; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/TelegramPanel.Core/Interfaces/IAccountService.cs: -------------------------------------------------------------------------------- 1 | using TelegramPanel.Core.Models; 2 | 3 | namespace TelegramPanel.Core.Interfaces; 4 | 5 | /// 6 | /// 账号服务接口 7 | /// 8 | public interface IAccountService 9 | { 10 | /// 11 | /// 发起手机号登录(发送验证码) 12 | /// 13 | Task StartLoginAsync(int accountId, string phone); 14 | 15 | /// 16 | /// 提交验证码完成登录 17 | /// 18 | Task SubmitCodeAsync(int accountId, string code); 19 | 20 | /// 21 | /// 重新发送验证码(可能切换到短信/电话等其它通道,取决于 Telegram 策略) 22 | /// 23 | Task ResendCodeAsync(int accountId); 24 | 25 | /// 26 | /// 提交两步验证密码 27 | /// 28 | Task SubmitPasswordAsync(int accountId, string password); 29 | 30 | /// 31 | /// 获取账号信息 32 | /// 33 | Task GetAccountInfoAsync(int accountId); 34 | 35 | /// 36 | /// 同步账号数据(频道、群组) 37 | /// 38 | Task SyncAccountDataAsync(int accountId); 39 | 40 | /// 41 | /// 检查账号状态 42 | /// 43 | Task CheckStatusAsync(int accountId); 44 | 45 | /// 46 | /// 释放并移除指定账号的 Telegram 客户端(用于避免 session 文件长期被占用)。 47 | /// 48 | Task ReleaseClientAsync(int accountId); 49 | } 50 | 51 | /// 52 | /// 登录结果 53 | /// 54 | public record LoginResult( 55 | bool Success, 56 | string? NextStep, // null=完成, "code"=需要验证码, "password"=需要密码, "signup"=需要注册 57 | string? Message, 58 | AccountInfo? Account = null 59 | ); 60 | 61 | /// 62 | /// 账号状态 63 | /// 64 | public enum AccountStatus 65 | { 66 | Active, 67 | Offline, 68 | Banned, 69 | Limited, 70 | NeedRelogin 71 | } 72 | -------------------------------------------------------------------------------- /src/TelegramPanel.Web/Services/LocalConfigFile.cs: -------------------------------------------------------------------------------- 1 | using System.Text; 2 | using Microsoft.AspNetCore.Hosting; 3 | using Microsoft.Extensions.Configuration; 4 | 5 | namespace TelegramPanel.Web.Services; 6 | 7 | public static class LocalConfigFile 8 | { 9 | public static string ResolvePath(IConfiguration configuration, IWebHostEnvironment environment) 10 | { 11 | var configured = (configuration["LocalConfig:Path"] ?? "").Trim(); 12 | if (!string.IsNullOrWhiteSpace(configured)) 13 | return configured; 14 | 15 | // Docker 部署默认持久化目录为 /data(docker-compose 挂载 ./docker-data:/data) 16 | // 即便没有显式配置,也优先写到 /data,避免写入镜像层 /app 导致丢失或权限问题。 17 | if (Directory.Exists("/data")) 18 | return "/data/appsettings.local.json"; 19 | 20 | return Path.Combine(environment.ContentRootPath, "appsettings.local.json"); 21 | } 22 | 23 | public static async Task EnsureExistsAsync(string path, CancellationToken cancellationToken = default) 24 | { 25 | var dir = Path.GetDirectoryName(path); 26 | if (!string.IsNullOrWhiteSpace(dir)) 27 | Directory.CreateDirectory(dir); 28 | 29 | if (File.Exists(path)) 30 | return; 31 | 32 | await File.WriteAllTextAsync(path, "{}", new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), cancellationToken); 33 | } 34 | 35 | public static async Task WriteJsonAtomicallyAsync(string path, string json, CancellationToken cancellationToken = default) 36 | { 37 | var dir = Path.GetDirectoryName(path); 38 | if (!string.IsNullOrWhiteSpace(dir)) 39 | Directory.CreateDirectory(dir); 40 | 41 | var tmp = $"{path}.tmp"; 42 | await File.WriteAllTextAsync(tmp, json, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), cancellationToken); 43 | File.Move(tmp, path, overwrite: true); 44 | } 45 | } 46 | 47 | -------------------------------------------------------------------------------- /src/TelegramPanel.Data/Repositories/AccountRepository.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | using TelegramPanel.Data.Entities; 3 | 4 | namespace TelegramPanel.Data.Repositories; 5 | 6 | /// 7 | /// 账号仓储实现 8 | /// 9 | public class AccountRepository : Repository, IAccountRepository 10 | { 11 | public AccountRepository(AppDbContext context) : base(context) 12 | { 13 | } 14 | 15 | public override async Task GetByIdAsync(int id) 16 | { 17 | return await _dbSet 18 | .Include(a => a.Category) 19 | .Include(a => a.Channels) 20 | .Include(a => a.Groups) 21 | .FirstOrDefaultAsync(a => a.Id == id); 22 | } 23 | 24 | public override async Task> GetAllAsync() 25 | { 26 | return await _dbSet 27 | .Include(a => a.Category) 28 | .ToListAsync(); 29 | } 30 | 31 | public async Task GetByPhoneAsync(string phone) 32 | { 33 | return await _dbSet 34 | .Include(a => a.Category) 35 | .FirstOrDefaultAsync(a => a.Phone == phone); 36 | } 37 | 38 | public async Task GetByUserIdAsync(long userId) 39 | { 40 | return await _dbSet 41 | .Include(a => a.Category) 42 | .FirstOrDefaultAsync(a => a.UserId == userId); 43 | } 44 | 45 | public async Task> GetByCategoryAsync(int categoryId) 46 | { 47 | return await _dbSet 48 | .Include(a => a.Category) 49 | .Where(a => a.CategoryId == categoryId) 50 | .ToListAsync(); 51 | } 52 | 53 | public async Task> GetActiveAccountsAsync() 54 | { 55 | return await _dbSet 56 | .Include(a => a.Category) 57 | .Where(a => a.IsActive) 58 | .ToListAsync(); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/TelegramPanel.Data/Entities/Account.cs: -------------------------------------------------------------------------------- 1 | namespace TelegramPanel.Data.Entities; 2 | 3 | /// 4 | /// 账号实体 5 | /// 6 | public class Account 7 | { 8 | public int Id { get; set; } 9 | public string Phone { get; set; } = null!; 10 | public long UserId { get; set; } 11 | /// 12 | /// 账号昵称(Telegram 显示名称) 13 | /// 14 | public string? Nickname { get; set; } 15 | public string? Username { get; set; } 16 | public string SessionPath { get; set; } = null!; 17 | public int ApiId { get; set; } 18 | public string ApiHash { get; set; } = null!; 19 | public bool IsActive { get; set; } = true; 20 | public int? CategoryId { get; set; } 21 | public DateTime CreatedAt { get; set; } = DateTime.UtcNow; 22 | public DateTime LastSyncAt { get; set; } = DateTime.UtcNow; 23 | 24 | /// 25 | /// 最后一次登录 Telegram 的时间(UTC),用于风控检查 26 | /// 27 | public DateTime? LastLoginAt { get; set; } 28 | 29 | /// 30 | /// Telegram 状态检测结果摘要(用于页面刷新后仍可展示上次检测结论) 31 | /// 32 | public string? TelegramStatusSummary { get; set; } 33 | 34 | /// 35 | /// Telegram 状态检测详情(错误码/原因等) 36 | /// 37 | public string? TelegramStatusDetails { get; set; } 38 | 39 | /// 40 | /// Telegram 状态检测是否成功(Ok) 41 | /// 42 | public bool? TelegramStatusOk { get; set; } 43 | 44 | /// 45 | /// Telegram 状态检测时间(UTC) 46 | /// 47 | public DateTime? TelegramStatusCheckedAtUtc { get; set; } 48 | 49 | // 导航属性 50 | public AccountCategory? Category { get; set; } 51 | public ICollection Channels { get; set; } = new List(); 52 | public ICollection AccountChannels { get; set; } = new List(); 53 | public ICollection Groups { get; set; } = new List(); 54 | } 55 | -------------------------------------------------------------------------------- /src/TelegramPanel.Data/Entities/AccountExtensions.cs: -------------------------------------------------------------------------------- 1 | namespace TelegramPanel.Data.Entities; 2 | 3 | /// 4 | /// Account 实体扩展方法 5 | /// 6 | public static class AccountExtensions 7 | { 8 | /// 9 | /// 获取风控检查用的参考时间(优先 LastLoginAt,其次 CreatedAt/LastSyncAt 作为估算兜底) 10 | /// 11 | public static DateTime? GetRiskReferenceAtUtc(this Account account) 12 | { 13 | if (account.LastLoginAt != null) 14 | return account.LastLoginAt.Value; 15 | 16 | // 兼容历史数据:早期导入的账号可能没有写入 LastLoginAt 17 | if (account.CreatedAt != default) 18 | return account.CreatedAt; 19 | 20 | if (account.LastSyncAt != default) 21 | return account.LastSyncAt; 22 | 23 | return null; 24 | } 25 | 26 | /// 27 | /// 风控参考时间是否为估算值(非真实登录时间) 28 | /// 29 | public static bool IsRiskReferenceEstimated(this Account account) 30 | => account.LastLoginAt == null && account.GetRiskReferenceAtUtc() != null; 31 | 32 | /// 33 | /// 获取风控检查用的参考小时数 34 | /// 35 | public static double? GetRiskReferenceHours(this Account account) 36 | { 37 | var at = account.GetRiskReferenceAtUtc(); 38 | if (at == null) 39 | return null; 40 | 41 | return (DateTime.UtcNow - at.Value).TotalHours; 42 | } 43 | 44 | /// 45 | /// 获取登录小时数的格式化字符串 46 | /// 47 | public static string GetLoginHoursFormatted(this Account account) 48 | { 49 | if (account.LastLoginAt == null) 50 | return "未知"; 51 | 52 | var hours = (DateTime.UtcNow - account.LastLoginAt.Value).TotalHours; 53 | return hours.ToString("F1"); 54 | } 55 | 56 | /// 57 | /// 获取登录小时数 58 | /// 59 | public static double? GetLoginHours(this Account account) 60 | { 61 | if (account.LastLoginAt == null) 62 | return null; 63 | 64 | return (DateTime.UtcNow - account.LastLoginAt.Value).TotalHours; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/TelegramPanel.Web/Components/Dialogs/AccountCategoryEditDialog.razor: -------------------------------------------------------------------------------- 1 | @namespace TelegramPanel.Web.Components.Dialogs 2 | @using TelegramPanel.Data.Entities 3 | 4 | 5 | 6 | 7 | 编辑账号分类 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 取消 16 | 17 | 保存 18 | 19 | 20 | 21 | 22 | @code 23 | { 24 | [CascadingParameter] private MudDialogInstance MudDialog { get; set; } = default!; 25 | 26 | [Parameter] public AccountCategory Category { get; set; } = default!; 27 | 28 | private string name = ""; 29 | private MudColor color = "#9E9E9E"; 30 | private string? description; 31 | 32 | protected override void OnParametersSet() 33 | { 34 | name = (Category?.Name ?? "").Trim(); 35 | description = (Category?.Description ?? "").Trim(); 36 | color = string.IsNullOrWhiteSpace(Category?.Color) ? "#9E9E9E" : Category!.Color!; 37 | } 38 | 39 | private void Cancel() 40 | { 41 | MudDialog.Cancel(); 42 | } 43 | 44 | private void Confirm() 45 | { 46 | var model = new AccountCategoryEditModel( 47 | Name: name.Trim(), 48 | Color: string.IsNullOrWhiteSpace(color.Value) ? null : color.Value, 49 | Description: string.IsNullOrWhiteSpace(description) ? null : description!.Trim()); 50 | 51 | MudDialog.Close(DialogResult.Ok(model)); 52 | } 53 | } 54 | 55 | -------------------------------------------------------------------------------- /src/TelegramPanel.Data/Repositories/AccountChannelRepository.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | using TelegramPanel.Data.Entities; 3 | 4 | namespace TelegramPanel.Data.Repositories; 5 | 6 | /// 7 | /// 账号-频道关联仓储实现 8 | /// 9 | public class AccountChannelRepository : Repository, IAccountChannelRepository 10 | { 11 | public AccountChannelRepository(AppDbContext context) : base(context) 12 | { 13 | } 14 | 15 | public async Task GetAsync(int accountId, int channelId) 16 | { 17 | return await _dbSet.FirstOrDefaultAsync(x => x.AccountId == accountId && x.ChannelId == channelId); 18 | } 19 | 20 | public async Task UpsertAsync(AccountChannel link) 21 | { 22 | var existing = await GetAsync(link.AccountId, link.ChannelId); 23 | if (existing == null) 24 | { 25 | await _dbSet.AddAsync(link); 26 | } 27 | else 28 | { 29 | existing.IsCreator = link.IsCreator; 30 | existing.IsAdmin = link.IsAdmin; 31 | existing.SyncedAt = link.SyncedAt; 32 | _dbSet.Update(existing); 33 | } 34 | 35 | await _context.SaveChangesAsync(); 36 | } 37 | 38 | public async Task DeleteForAccountExceptAsync(int accountId, IReadOnlyCollection keepChannelIds) 39 | { 40 | var keep = keepChannelIds.ToHashSet(); 41 | var toDelete = await _dbSet 42 | .Where(x => x.AccountId == accountId && !keep.Contains(x.ChannelId)) 43 | .ToListAsync(); 44 | 45 | if (toDelete.Count == 0) 46 | return; 47 | 48 | _dbSet.RemoveRange(toDelete); 49 | await _context.SaveChangesAsync(); 50 | } 51 | 52 | public async Task GetPreferredAdminAccountIdAsync(int channelId) 53 | { 54 | return await _dbSet 55 | .Where(x => x.ChannelId == channelId && x.IsAdmin) 56 | .OrderByDescending(x => x.IsCreator) 57 | .ThenByDescending(x => x.SyncedAt) 58 | .Select(x => (int?)x.AccountId) 59 | .FirstOrDefaultAsync(); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/TelegramPanel.Data/Migrations/20251219000003_AddAccountTelegramStatusCache.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.EntityFrameworkCore.Infrastructure; 3 | using Microsoft.EntityFrameworkCore.Migrations; 4 | 5 | #nullable disable 6 | 7 | namespace TelegramPanel.Data.Migrations 8 | { 9 | [DbContext(typeof(AppDbContext))] 10 | [Migration("20251219000003_AddAccountTelegramStatusCache")] 11 | public partial class AddAccountTelegramStatusCache : Migration 12 | { 13 | protected override void Up(MigrationBuilder migrationBuilder) 14 | { 15 | migrationBuilder.AddColumn( 16 | name: "TelegramStatusSummary", 17 | table: "Accounts", 18 | type: "TEXT", 19 | maxLength: 200, 20 | nullable: true); 21 | 22 | migrationBuilder.AddColumn( 23 | name: "TelegramStatusDetails", 24 | table: "Accounts", 25 | type: "TEXT", 26 | maxLength: 2000, 27 | nullable: true); 28 | 29 | migrationBuilder.AddColumn( 30 | name: "TelegramStatusOk", 31 | table: "Accounts", 32 | type: "INTEGER", 33 | nullable: true); 34 | 35 | migrationBuilder.AddColumn( 36 | name: "TelegramStatusCheckedAtUtc", 37 | table: "Accounts", 38 | type: "TEXT", 39 | nullable: true); 40 | } 41 | 42 | protected override void Down(MigrationBuilder migrationBuilder) 43 | { 44 | migrationBuilder.DropColumn( 45 | name: "TelegramStatusSummary", 46 | table: "Accounts"); 47 | 48 | migrationBuilder.DropColumn( 49 | name: "TelegramStatusDetails", 50 | table: "Accounts"); 51 | 52 | migrationBuilder.DropColumn( 53 | name: "TelegramStatusOk", 54 | table: "Accounts"); 55 | 56 | migrationBuilder.DropColumn( 57 | name: "TelegramStatusCheckedAtUtc", 58 | table: "Accounts"); 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /TelegramPanel.sln: -------------------------------------------------------------------------------- 1 | Microsoft Visual Studio Solution File, Format Version 12.00 2 | # Visual Studio Version 17 3 | VisualStudioVersion = 17.0.31903.59 4 | MinimumVisualStudioVersion = 10.0.40219.1 5 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TelegramPanel.Web", "src\TelegramPanel.Web\TelegramPanel.Web.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}" 6 | EndProject 7 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TelegramPanel.Core", "src\TelegramPanel.Core\TelegramPanel.Core.csproj", "{B2C3D4E5-F678-9012-BCDE-F12345678901}" 8 | EndProject 9 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TelegramPanel.Data", "src\TelegramPanel.Data\TelegramPanel.Data.csproj", "{C3D4E5F6-7890-1234-CDEF-123456789012}" 10 | EndProject 11 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TelegramPanel.Modules.Abstractions", "src\TelegramPanel.Modules.Abstractions\TelegramPanel.Modules.Abstractions.csproj", "{D47E13D1-8E3C-4F40-9B6D-4B8E17A8C5F1}" 12 | EndProject 13 | Global 14 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 15 | Debug|Any CPU = Debug|Any CPU 16 | Release|Any CPU = Release|Any CPU 17 | EndGlobalSection 18 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 19 | {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 20 | {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU 21 | {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU 22 | {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU 23 | {B2C3D4E5-F678-9012-BCDE-F12345678901}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 24 | {B2C3D4E5-F678-9012-BCDE-F12345678901}.Debug|Any CPU.Build.0 = Debug|Any CPU 25 | {B2C3D4E5-F678-9012-BCDE-F12345678901}.Release|Any CPU.ActiveCfg = Release|Any CPU 26 | {B2C3D4E5-F678-9012-BCDE-F12345678901}.Release|Any CPU.Build.0 = Release|Any CPU 27 | {C3D4E5F6-7890-1234-CDEF-123456789012}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 28 | {C3D4E5F6-7890-1234-CDEF-123456789012}.Debug|Any CPU.Build.0 = Debug|Any CPU 29 | {C3D4E5F6-7890-1234-CDEF-123456789012}.Release|Any CPU.ActiveCfg = Release|Any CPU 30 | {C3D4E5F6-7890-1234-CDEF-123456789012}.Release|Any CPU.Build.0 = Release|Any CPU 31 | {D47E13D1-8E3C-4F40-9B6D-4B8E17A8C5F1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 32 | {D47E13D1-8E3C-4F40-9B6D-4B8E17A8C5F1}.Debug|Any CPU.Build.0 = Debug|Any CPU 33 | {D47E13D1-8E3C-4F40-9B6D-4B8E17A8C5F1}.Release|Any CPU.ActiveCfg = Release|Any CPU 34 | {D47E13D1-8E3C-4F40-9B6D-4B8E17A8C5F1}.Release|Any CPU.Build.0 = Release|Any CPU 35 | EndGlobalSection 36 | EndGlobal 37 | -------------------------------------------------------------------------------- /src/TelegramPanel.Core/Services/GroupManagementService.cs: -------------------------------------------------------------------------------- 1 | using TelegramPanel.Data.Entities; 2 | using TelegramPanel.Data.Repositories; 3 | 4 | namespace TelegramPanel.Core.Services; 5 | 6 | /// 7 | /// 群组数据管理服务 8 | /// 9 | public class GroupManagementService 10 | { 11 | private readonly IGroupRepository _groupRepository; 12 | 13 | public GroupManagementService(IGroupRepository groupRepository) 14 | { 15 | _groupRepository = groupRepository; 16 | } 17 | 18 | public async Task GetGroupAsync(int id) 19 | { 20 | return await _groupRepository.GetByIdAsync(id); 21 | } 22 | 23 | public async Task GetGroupByTelegramIdAsync(long telegramId) 24 | { 25 | return await _groupRepository.GetByTelegramIdAsync(telegramId); 26 | } 27 | 28 | public async Task> GetAllGroupsAsync() 29 | { 30 | return await _groupRepository.GetAllAsync(); 31 | } 32 | 33 | public async Task> GetGroupsByCreatorAsync(int accountId) 34 | { 35 | return await _groupRepository.GetByCreatorAccountAsync(accountId); 36 | } 37 | 38 | public async Task CreateOrUpdateGroupAsync(Group group) 39 | { 40 | var existing = await _groupRepository.GetByTelegramIdAsync(group.TelegramId); 41 | if (existing != null) 42 | { 43 | // 更新现有群组 44 | existing.Title = group.Title; 45 | existing.Username = group.Username; 46 | existing.MemberCount = group.MemberCount; 47 | existing.About = group.About; 48 | existing.AccessHash = group.AccessHash; 49 | existing.SyncedAt = DateTime.UtcNow; 50 | 51 | await _groupRepository.UpdateAsync(existing); 52 | return existing; 53 | } 54 | else 55 | { 56 | // 创建新群组 57 | group.SyncedAt = DateTime.UtcNow; 58 | return await _groupRepository.AddAsync(group); 59 | } 60 | } 61 | 62 | public async Task DeleteGroupAsync(int id) 63 | { 64 | var group = await _groupRepository.GetByIdAsync(id); 65 | if (group != null) 66 | { 67 | await _groupRepository.DeleteAsync(group); 68 | } 69 | } 70 | 71 | public async Task GetTotalGroupCountAsync() 72 | { 73 | return await _groupRepository.CountAsync(); 74 | } 75 | 76 | public async Task GetGroupCountByCreatorAsync(int accountId) 77 | { 78 | return await _groupRepository.CountAsync(g => g.CreatorAccountId == accountId); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/TelegramPanel.Web/Modules/ModuleStateStore.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json; 2 | 3 | namespace TelegramPanel.Web.Modules; 4 | 5 | public sealed class ModuleStateStore 6 | { 7 | private readonly ModuleLayout _layout; 8 | private readonly JsonSerializerOptions _jsonOptions = new() { WriteIndented = true }; 9 | 10 | public ModuleStateStore(ModuleLayout layout) 11 | { 12 | _layout = layout; 13 | } 14 | 15 | public async Task LoadAsync() 16 | { 17 | EnsureDirectories(); 18 | 19 | if (!File.Exists(_layout.StateFile)) 20 | return new ModuleState(); 21 | 22 | var json = await File.ReadAllTextAsync(_layout.StateFile); 23 | if (string.IsNullOrWhiteSpace(json)) 24 | return new ModuleState(); 25 | 26 | return JsonSerializer.Deserialize(json) ?? new ModuleState(); 27 | } 28 | 29 | public async Task SaveAsync(ModuleState state) 30 | { 31 | EnsureDirectories(); 32 | 33 | state.SchemaVersion = 1; 34 | state.Modules ??= new List(); 35 | 36 | var json = JsonSerializer.Serialize(state, _jsonOptions); 37 | var target = _layout.StateFile; 38 | var dir = Path.GetDirectoryName(target) ?? _layout.Root; 39 | Directory.CreateDirectory(dir); 40 | 41 | var temp = target + ".tmp"; 42 | await File.WriteAllTextAsync(temp, json, new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); 43 | if (File.Exists(target)) 44 | File.Replace(temp, target, destinationBackupFileName: null); 45 | else 46 | File.Move(temp, target); 47 | } 48 | 49 | private void EnsureDirectories() 50 | { 51 | Directory.CreateDirectory(_layout.Root); 52 | Directory.CreateDirectory(_layout.PackagesDir); 53 | Directory.CreateDirectory(_layout.InstalledDir); 54 | Directory.CreateDirectory(_layout.ActiveDir); 55 | Directory.CreateDirectory(_layout.StagingDir); 56 | Directory.CreateDirectory(_layout.TrashDir); 57 | } 58 | } 59 | 60 | public sealed class ModuleState 61 | { 62 | public int SchemaVersion { get; set; } = 1; 63 | public List Modules { get; set; } = new(); 64 | } 65 | 66 | public sealed class ModuleStateItem 67 | { 68 | public string Id { get; set; } = ""; 69 | public bool Enabled { get; set; } 70 | public string? ActiveVersion { get; set; } 71 | public string? LastGoodVersion { get; set; } 72 | public List InstalledVersions { get; set; } = new(); 73 | 74 | public bool BuiltIn { get; set; } 75 | } 76 | 77 | -------------------------------------------------------------------------------- /src/TelegramPanel.Web/Modules/ModulePaths.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | using TelegramPanel.Modules; 3 | 4 | namespace TelegramPanel.Web.Modules; 5 | 6 | public static class ModulePaths 7 | { 8 | public static string GetHostVersion() 9 | { 10 | // 优先取 InformationalVersion(通常包含语义版本);取不到则退回 AssemblyVersion。 11 | var asm = Assembly.GetExecutingAssembly(); 12 | var info = asm.GetCustomAttribute()?.InformationalVersion; 13 | info = (info ?? "").Trim(); 14 | if (SemVer.TryParse(NormalizeSemVer(info), out var v)) 15 | return v.ToString(); 16 | 17 | var ver = asm.GetName().Version; 18 | if (ver != null) 19 | return $"{ver.Major}.{ver.Minor}.{(ver.Build < 0 ? 0 : ver.Build)}"; 20 | 21 | return "0.0.0"; 22 | } 23 | 24 | public static string ResolveModulesRoot(IConfiguration configuration, IWebHostEnvironment environment) 25 | { 26 | var configured = (configuration["Modules:RootPath"] ?? "").Trim(); 27 | if (!string.IsNullOrWhiteSpace(configured)) 28 | { 29 | if (Path.IsPathRooted(configured)) 30 | return configured; 31 | 32 | return Path.Combine(environment.ContentRootPath, configured); 33 | } 34 | 35 | // Docker 默认把持久化目录挂到 /data 36 | if (Directory.Exists("/data")) 37 | return "/data/modules"; 38 | 39 | return Path.Combine(environment.ContentRootPath, "modules"); 40 | } 41 | 42 | public static ModuleLayout GetLayout(string root) 43 | { 44 | root = root.Trim(); 45 | return new ModuleLayout( 46 | Root: root, 47 | StateFile: Path.Combine(root, "state.json"), 48 | PackagesDir: Path.Combine(root, "packages"), 49 | InstalledDir: Path.Combine(root, "installed"), 50 | ActiveDir: Path.Combine(root, "active"), 51 | StagingDir: Path.Combine(root, "staging"), 52 | TrashDir: Path.Combine(root, "trash")); 53 | } 54 | 55 | private static string NormalizeSemVer(string value) 56 | { 57 | // InformationalVersion 可能是 "1.2.3+sha" 或 "1.2.3-rc.1" 58 | var s = value; 59 | var plus = s.IndexOf('+'); 60 | if (plus >= 0) s = s.Substring(0, plus); 61 | var dash = s.IndexOf('-'); 62 | if (dash >= 0) s = s.Substring(0, dash); 63 | return s.Trim(); 64 | } 65 | } 66 | 67 | public sealed record ModuleLayout( 68 | string Root, 69 | string StateFile, 70 | string PackagesDir, 71 | string InstalledDir, 72 | string ActiveDir, 73 | string StagingDir, 74 | string TrashDir); 75 | -------------------------------------------------------------------------------- /tools/package-module.ps1: -------------------------------------------------------------------------------- 1 | param( 2 | [Parameter(Mandatory = $true)] 3 | [string]$Project, 4 | 5 | [Parameter(Mandatory = $true)] 6 | [string]$Manifest, 7 | 8 | [Parameter(Mandatory = $false)] 9 | [string]$OutDir = "artifacts/modules" 10 | ) 11 | 12 | $ErrorActionPreference = "Stop" 13 | 14 | $repoRoot = (Resolve-Path ".").Path 15 | $projectPath = Join-Path $repoRoot $Project 16 | $manifestPath = Join-Path $repoRoot $Manifest 17 | $outRoot = Join-Path $repoRoot $OutDir 18 | 19 | if (-not (Test-Path -Path $projectPath)) { throw "Project 不存在:$Project" } 20 | if (-not (Test-Path -Path $manifestPath)) { throw "Manifest 不存在:$Manifest" } 21 | 22 | $manifestObj = Get-Content -Path $manifestPath -Raw | ConvertFrom-Json 23 | $moduleId = "" 24 | $version = "" 25 | if ($null -ne $manifestObj.id) { $moduleId = [string]$manifestObj.id } 26 | if ($null -ne $manifestObj.version) { $version = [string]$manifestObj.version } 27 | $moduleId = $moduleId.Trim() 28 | $version = $version.Trim() 29 | if ([string]::IsNullOrWhiteSpace($moduleId)) { throw "manifest.json 缺少 id" } 30 | if ([string]::IsNullOrWhiteSpace($version)) { throw "manifest.json 缺少 version" } 31 | 32 | $buildRootRel = "artifacts/_modulebuild/$moduleId/$version" 33 | $publishRel = "$buildRootRel/publish" 34 | $stagingRel = "$buildRootRel/staging" 35 | 36 | $publishHost = Join-Path $repoRoot $publishRel 37 | $stagingHost = Join-Path $repoRoot $stagingRel 38 | 39 | New-Item -ItemType Directory -Force -Path $publishHost | Out-Null 40 | New-Item -ItemType Directory -Force -Path $stagingHost | Out-Null 41 | 42 | $publishContainer = "/src/$publishRel" 43 | $projectContainer = "/src/$Project" 44 | 45 | Write-Host "Building module with Docker..." -ForegroundColor Cyan 46 | docker run --rm ` 47 | -v "${repoRoot}:/src" ` 48 | -w "/src" ` 49 | mcr.microsoft.com/dotnet/sdk:8.0 ` 50 | dotnet publish "$projectContainer" -c Release -o "$publishContainer" /p:UseAppHost=false 51 | if ($LASTEXITCODE -ne 0) { throw "dotnet publish 失败(退出码:$LASTEXITCODE)" } 52 | 53 | $stagingLib = Join-Path $stagingHost "lib" 54 | New-Item -ItemType Directory -Force -Path $stagingLib | Out-Null 55 | 56 | Copy-Item -Path $manifestPath -Destination (Join-Path $stagingHost "manifest.json") -Force 57 | Copy-Item -Path (Join-Path $publishHost "*") -Destination $stagingLib -Recurse -Force 58 | 59 | New-Item -ItemType Directory -Force -Path $outRoot | Out-Null 60 | $dest = Join-Path $outRoot "$moduleId-$version.tpm" 61 | if (Test-Path -Path $dest) { Remove-Item -Path $dest -Force } 62 | 63 | $destZip = [System.IO.Path]::ChangeExtension($dest, ".zip") 64 | if (Test-Path -Path $destZip) { Remove-Item -Path $destZip -Force } 65 | 66 | Compress-Archive -Path (Join-Path $stagingHost "*") -DestinationPath $destZip -Force 67 | Move-Item -Path $destZip -Destination $dest -Force 68 | 69 | Write-Host "OK: $dest" -ForegroundColor Green 70 | -------------------------------------------------------------------------------- /src/TelegramPanel.Web/Components/Layout/MainLayout.razor: -------------------------------------------------------------------------------- 1 | @inherits LayoutComponentBase 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | Telegram Panel 12 | 13 | 17 | 23 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | @Body 35 | 36 | 37 | 38 | 39 | @using TelegramPanel.Web.Services 40 | @inject UiPreferencesService UiPreferences 41 | 42 | @code { 43 | private bool _drawerOpen = true; 44 | private bool _isDarkMode = true; 45 | 46 | private MudTheme _theme = new() 47 | { 48 | PaletteLight = new PaletteLight 49 | { 50 | Primary = "#1976d2", 51 | Secondary = "#00bcd4", 52 | AppbarBackground = "#1976d2" 53 | }, 54 | PaletteDark = new PaletteDark 55 | { 56 | Primary = "#90caf9", 57 | Secondary = "#80deea", 58 | AppbarBackground = "#1e1e2d", 59 | Background = "#121212", 60 | Surface = "#1e1e2d" 61 | } 62 | }; 63 | 64 | protected override async Task OnInitializedAsync() 65 | { 66 | // 从配置文件读取用户的主题偏好 67 | _isDarkMode = await UiPreferences.GetIsDarkModeAsync(); 68 | // 确保初始化后 UI 能正确显示主题 69 | StateHasChanged(); 70 | } 71 | 72 | private void ToggleDrawer() 73 | { 74 | _drawerOpen = !_drawerOpen; 75 | } 76 | 77 | private async Task ToggleDarkMode() 78 | { 79 | _isDarkMode = !_isDarkMode; 80 | // 保存主题偏好到配置文件 81 | await UiPreferences.SetIsDarkModeAsync(_isDarkMode); 82 | // 强制刷新组件状态,确保在异步操作后 UI 能及时更新 83 | StateHasChanged(); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/TelegramPanel.Web/Components/Pages/ModulePageHost.razor: -------------------------------------------------------------------------------- 1 | @page "/ext/{ModuleId}/{PageKey}" 2 | @using Microsoft.AspNetCore.Components 3 | @using TelegramPanel.Web.Modules 4 | @inject ModuleContributionRegistry Contributions 5 | 6 | 扩展页面 - Telegram Panel 7 | 8 | @if (error != null) 9 | { 10 | @error 11 | } 12 | else if (componentType == null) 13 | { 14 | 15 | } 16 | else 17 | { 18 | 19 | } 20 | 21 | @code 22 | { 23 | [Parameter] public string ModuleId { get; set; } = ""; 24 | [Parameter] public string PageKey { get; set; } = ""; 25 | 26 | private string? error; 27 | private Type? componentType; 28 | private Dictionary parameters = new(); 29 | 30 | protected override void OnParametersSet() 31 | { 32 | error = null; 33 | componentType = null; 34 | parameters = new Dictionary(); 35 | 36 | var moduleId = (ModuleId ?? "").Trim(); 37 | var pageKey = (PageKey ?? "").Trim(); 38 | if (moduleId.Length == 0 || pageKey.Length == 0) 39 | { 40 | error = "参数无效"; 41 | return; 42 | } 43 | 44 | var page = Contributions.Pages.FirstOrDefault(p => 45 | string.Equals(p.Module.Id, moduleId, StringComparison.OrdinalIgnoreCase) 46 | && string.Equals(p.Definition.Key, pageKey, StringComparison.OrdinalIgnoreCase)); 47 | 48 | if (page == null) 49 | { 50 | error = $"未找到扩展页面:{moduleId}/{pageKey}"; 51 | return; 52 | } 53 | 54 | var typeName = (page.Definition.ComponentType ?? "").Trim(); 55 | if (typeName.Length == 0) 56 | { 57 | error = "模块页面未配置 componentType"; 58 | return; 59 | } 60 | 61 | var moduleTypeName = NormalizeModuleTypeName(typeName); 62 | componentType = 63 | Type.GetType(typeName, throwOnError: false, ignoreCase: false) 64 | ?? page.Module.Instance.GetType().Assembly.GetType(moduleTypeName, throwOnError: false, ignoreCase: false); 65 | 66 | if (componentType == null) 67 | { 68 | error = $"无法加载组件类型:{typeName}"; 69 | return; 70 | } 71 | 72 | parameters = new Dictionary 73 | { 74 | ["ModuleId"] = moduleId, 75 | ["PageKey"] = pageKey 76 | }; 77 | } 78 | 79 | private static string NormalizeModuleTypeName(string typeName) 80 | { 81 | typeName = (typeName ?? "").Trim(); 82 | if (typeName.Length == 0) 83 | return typeName; 84 | 85 | var comma = typeName.IndexOf(',', StringComparison.Ordinal); 86 | if (comma > 0) 87 | return typeName.Substring(0, comma).Trim(); 88 | 89 | return typeName; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/TelegramPanel.Web/Services/AccountDataAutoSyncBackgroundService.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.DependencyInjection; 2 | using Microsoft.Extensions.Hosting; 3 | using Microsoft.Extensions.Logging; 4 | 5 | namespace TelegramPanel.Web.Services; 6 | 7 | /// 8 | /// 账号频道/群组数据自动同步(后台定时)。 9 | /// 10 | public sealed class AccountDataAutoSyncBackgroundService : BackgroundService 11 | { 12 | private readonly IServiceScopeFactory _scopeFactory; 13 | private readonly IConfiguration _configuration; 14 | private readonly ILogger _logger; 15 | 16 | public AccountDataAutoSyncBackgroundService( 17 | IServiceScopeFactory scopeFactory, 18 | IConfiguration configuration, 19 | ILogger logger) 20 | { 21 | _scopeFactory = scopeFactory; 22 | _configuration = configuration; 23 | _logger = logger; 24 | } 25 | 26 | protected override async Task ExecuteAsync(CancellationToken stoppingToken) 27 | { 28 | // 延迟一点,避免与启动时 DB 迁移抢资源 29 | await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken); 30 | 31 | while (!stoppingToken.IsCancellationRequested) 32 | { 33 | var enabled = _configuration.GetValue("Sync:AutoSyncEnabled", false); 34 | var hours = _configuration.GetValue("Sync:IntervalHours", 6); 35 | if (hours < 1) hours = 1; 36 | if (hours > 24) hours = 24; 37 | 38 | if (!enabled) 39 | { 40 | await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken); 41 | continue; 42 | } 43 | 44 | try 45 | { 46 | using var scope = _scopeFactory.CreateScope(); 47 | var dataSync = scope.ServiceProvider.GetRequiredService(); 48 | 49 | _logger.LogInformation("Account auto sync started"); 50 | var summary = await dataSync.SyncAllActiveAccountsAsync(stoppingToken); 51 | if (summary.AccountFailures.Count > 0) 52 | { 53 | foreach (var f in summary.AccountFailures) 54 | _logger.LogWarning("Account auto sync failed: {Phone} {Error}", f.Phone, f.Error); 55 | } 56 | 57 | _logger.LogInformation("Account auto sync completed: {Channels} channels, {Groups} groups (failures={Failures})", 58 | summary.TotalChannelsSynced, summary.TotalGroupsSynced, summary.AccountFailures.Count); 59 | } 60 | catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) 61 | { 62 | // ignore 63 | } 64 | catch (Exception ex) 65 | { 66 | _logger.LogWarning(ex, "Account auto sync failed"); 67 | } 68 | 69 | await Task.Delay(TimeSpan.FromHours(hours), stoppingToken); 70 | } 71 | } 72 | } 73 | 74 | -------------------------------------------------------------------------------- /src/TelegramPanel.Web/Modules/ModuleLoadContext.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | using System.Runtime.Loader; 3 | 4 | namespace TelegramPanel.Web.Modules; 5 | 6 | public sealed class ModuleLoadContext : AssemblyLoadContext 7 | { 8 | private readonly AssemblyDependencyResolver _resolver; 9 | private readonly string _baseDir; 10 | 11 | public ModuleLoadContext(string mainAssemblyPath) : base(isCollectible: false) 12 | { 13 | if (string.IsNullOrWhiteSpace(mainAssemblyPath)) 14 | throw new ArgumentException("mainAssemblyPath 不能为空", nameof(mainAssemblyPath)); 15 | 16 | _resolver = new AssemblyDependencyResolver(mainAssemblyPath); 17 | _baseDir = Path.GetDirectoryName(mainAssemblyPath) ?? ""; 18 | } 19 | 20 | protected override Assembly? Load(AssemblyName assemblyName) 21 | { 22 | var name = (assemblyName.Name ?? "").Trim(); 23 | if (name.Length == 0) 24 | return null; 25 | 26 | // 1) 与宿主共享的“边界程序集”必须由 Default ALC 解析,避免同名程序集被模块再次加载导致类型身份不一致。 27 | // 典型问题:模块包携带 Microsoft.Extensions.DependencyInjection*.dll / Microsoft.AspNetCore.Components.dll, 28 | // 会让 IServiceCollection / EventCallback 等类型在不同 ALC 中变成“不同类型”,最终出现: 29 | // - TypeLoadException(看起来像模块没有实现 ConfigureServices) 30 | // - 组件参数绑定失败(表现为 DraftChanged 不生效) 31 | if (name.StartsWith("Microsoft.Extensions.", StringComparison.OrdinalIgnoreCase) 32 | || name.StartsWith("Microsoft.AspNetCore.", StringComparison.OrdinalIgnoreCase) 33 | || name.StartsWith("Microsoft.JSInterop", StringComparison.OrdinalIgnoreCase) 34 | || name.StartsWith("TelegramPanel.", StringComparison.OrdinalIgnoreCase) 35 | || string.Equals(name, "MudBlazor", StringComparison.OrdinalIgnoreCase)) 36 | return null; 37 | 38 | // 2) 其次:如果宿主已经加载过同名程序集,则同样交给 Default ALC(避免重复加载)。 39 | if (AssemblyLoadContext.Default.Assemblies.Any(a => 40 | string.Equals(a.GetName().Name, name, StringComparison.OrdinalIgnoreCase))) 41 | return null; 42 | 43 | var path = _resolver.ResolveAssemblyToPath(assemblyName); 44 | if (!string.IsNullOrWhiteSpace(path) && File.Exists(path)) 45 | return LoadFromAssemblyPath(path); 46 | 47 | // fallback:如果模块未提供 deps.json,则在 lib 目录按名称寻找 48 | if (name.Length == 0 || string.IsNullOrWhiteSpace(_baseDir)) 49 | return null; 50 | 51 | var candidate = Path.Combine(_baseDir, name + ".dll"); 52 | if (File.Exists(candidate)) 53 | return LoadFromAssemblyPath(candidate); 54 | 55 | return null; 56 | } 57 | 58 | protected override IntPtr LoadUnmanagedDll(string unmanagedDllName) 59 | { 60 | var path = _resolver.ResolveUnmanagedDllToPath(unmanagedDllName); 61 | if (!string.IsNullOrWhiteSpace(path) && File.Exists(path)) 62 | return LoadUnmanagedDllFromPath(path); 63 | 64 | return base.LoadUnmanagedDll(unmanagedDllName); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/TelegramPanel.Web/Services/UiPreferencesService.cs: -------------------------------------------------------------------------------- 1 | using System.Text; 2 | using System.Text.Json; 3 | using System.Text.Json.Nodes; 4 | using Microsoft.AspNetCore.Hosting; 5 | 6 | namespace TelegramPanel.Web.Services; 7 | 8 | /// 9 | /// UI 偏好设置服务,用于管理用户界面相关的配置(如主题模式) 10 | /// 11 | public class UiPreferencesService 12 | { 13 | private readonly IConfiguration _configuration; 14 | private readonly IWebHostEnvironment _environment; 15 | private readonly string _configFilePath; 16 | private readonly SemaphoreSlim _writeLock = new(1, 1); 17 | 18 | public UiPreferencesService(IConfiguration configuration, IWebHostEnvironment environment) 19 | { 20 | _configuration = configuration; 21 | _environment = environment; 22 | _configFilePath = LocalConfigFile.ResolvePath(configuration, environment); 23 | } 24 | 25 | /// 26 | /// 获取当前的深色模式设置 27 | /// 28 | public async Task GetIsDarkModeAsync() 29 | { 30 | try 31 | { 32 | if (!File.Exists(_configFilePath)) 33 | return true; // 默认使用深色模式 34 | 35 | var json = await File.ReadAllTextAsync(_configFilePath); 36 | var doc = JsonDocument.Parse(json); 37 | 38 | if (doc.RootElement.TryGetProperty("UI", out var uiSection) && 39 | uiSection.TryGetProperty("IsDarkMode", out var isDarkModeValue)) 40 | { 41 | return isDarkModeValue.GetBoolean(); 42 | } 43 | 44 | return true; // 默认使用深色模式 45 | } 46 | catch 47 | { 48 | return true; // 出错时默认使用深色模式 49 | } 50 | } 51 | 52 | /// 53 | /// 设置深色模式 54 | /// 55 | public async Task SetIsDarkModeAsync(bool isDarkMode) 56 | { 57 | await _writeLock.WaitAsync(); 58 | try 59 | { 60 | // 确保配置文件存在 61 | await LocalConfigFile.EnsureExistsAsync(_configFilePath); 62 | 63 | // 读取现有配置 64 | var json = await File.ReadAllTextAsync(_configFilePath); 65 | var jsonObj = JsonNode.Parse(json)?.AsObject() ?? new JsonObject(); 66 | 67 | // 更新 UI 配置 68 | if (!jsonObj.ContainsKey("UI")) 69 | { 70 | jsonObj["UI"] = new JsonObject(); 71 | } 72 | 73 | var uiSection = jsonObj["UI"]!.AsObject(); 74 | uiSection["IsDarkMode"] = isDarkMode; 75 | 76 | // 写入配置文件 77 | var options = new JsonSerializerOptions 78 | { 79 | WriteIndented = true, 80 | Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping 81 | }; 82 | var updatedJson = JsonSerializer.Serialize(jsonObj, options); 83 | await LocalConfigFile.WriteJsonAtomicallyAsync(_configFilePath, updatedJson); 84 | } 85 | finally 86 | { 87 | _writeLock.Release(); 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/TelegramPanel.Data/Repositories/ChannelRepository.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | using TelegramPanel.Data.Entities; 3 | 4 | namespace TelegramPanel.Data.Repositories; 5 | 6 | /// 7 | /// 频道仓储实现 8 | /// 9 | public class ChannelRepository : Repository, IChannelRepository 10 | { 11 | public ChannelRepository(AppDbContext context) : base(context) 12 | { 13 | } 14 | 15 | public override async Task GetByIdAsync(int id) 16 | { 17 | return await _dbSet 18 | .Include(c => c.CreatorAccount) 19 | .Include(c => c.Group) 20 | .FirstOrDefaultAsync(c => c.Id == id); 21 | } 22 | 23 | public override async Task> GetAllAsync() 24 | { 25 | return await _dbSet 26 | .Include(c => c.CreatorAccount) 27 | .Include(c => c.Group) 28 | .OrderByDescending(c => c.SyncedAt) 29 | .ToListAsync(); 30 | } 31 | 32 | public async Task GetByTelegramIdAsync(long telegramId) 33 | { 34 | return await _dbSet 35 | .Include(c => c.CreatorAccount) 36 | .Include(c => c.Group) 37 | .FirstOrDefaultAsync(c => c.TelegramId == telegramId); 38 | } 39 | 40 | public async Task> GetCreatedAsync() 41 | { 42 | return await _dbSet 43 | .Include(c => c.CreatorAccount) 44 | .Include(c => c.Group) 45 | .Where(c => c.CreatorAccountId != null) 46 | .OrderByDescending(c => c.SyncedAt) 47 | .ToListAsync(); 48 | } 49 | 50 | public async Task> GetByCreatorAccountAsync(int accountId) 51 | { 52 | return await _dbSet 53 | .Include(c => c.CreatorAccount) 54 | .Include(c => c.Group) 55 | .Where(c => c.CreatorAccountId == accountId) 56 | .OrderByDescending(c => c.SyncedAt) 57 | .ToListAsync(); 58 | } 59 | 60 | public async Task> GetForAccountAsync(int accountId, bool includeNonCreator) 61 | { 62 | var links = _context.Set() 63 | .Where(x => x.AccountId == accountId && (includeNonCreator || x.IsCreator)); 64 | 65 | return await _dbSet 66 | .Include(c => c.CreatorAccount) 67 | .Include(c => c.Group) 68 | .Where(c => links.Any(x => x.ChannelId == c.Id)) 69 | .OrderByDescending(c => c.SyncedAt) 70 | .ToListAsync(); 71 | } 72 | 73 | public async Task> GetByGroupAsync(int groupId) 74 | { 75 | return await _dbSet 76 | .Include(c => c.CreatorAccount) 77 | .Include(c => c.Group) 78 | .Where(c => c.GroupId == groupId) 79 | .OrderByDescending(c => c.SyncedAt) 80 | .ToListAsync(); 81 | } 82 | 83 | public async Task> GetBroadcastChannelsAsync() 84 | { 85 | return await _dbSet 86 | .Include(c => c.CreatorAccount) 87 | .Include(c => c.Group) 88 | .Where(c => c.IsBroadcast) 89 | .OrderByDescending(c => c.SyncedAt) 90 | .ToListAsync(); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/TelegramPanel.Web/Services/PanelTimeZoneService.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Options; 2 | 3 | namespace TelegramPanel.Web.Services; 4 | 5 | public sealed class PanelTimeZoneService 6 | { 7 | private readonly object _gate = new(); 8 | private TimeZoneInfo _timeZone = TimeZoneInfo.Utc; 9 | 10 | public PanelTimeZoneService(IOptionsMonitor optionsMonitor) 11 | { 12 | Apply(optionsMonitor.CurrentValue); 13 | optionsMonitor.OnChange(Apply); 14 | } 15 | 16 | public TimeZoneInfo Current 17 | { 18 | get 19 | { 20 | lock (_gate) 21 | { 22 | return _timeZone; 23 | } 24 | } 25 | } 26 | 27 | public string Format(DateTime? valueUtcOrUnspecified, string format = "yyyy-MM-dd HH:mm", string emptyText = "-") 28 | { 29 | if (valueUtcOrUnspecified == null) 30 | return emptyText; 31 | 32 | var converted = ConvertFromUtcOrUnspecified(valueUtcOrUnspecified.Value); 33 | return converted.ToString(format); 34 | } 35 | 36 | public DateTime ConvertFromUtcOrUnspecified(DateTime valueUtcOrUnspecified) 37 | { 38 | var utc = valueUtcOrUnspecified.Kind switch 39 | { 40 | DateTimeKind.Utc => valueUtcOrUnspecified, 41 | DateTimeKind.Local => valueUtcOrUnspecified.ToUniversalTime(), 42 | _ => DateTime.SpecifyKind(valueUtcOrUnspecified, DateTimeKind.Utc) 43 | }; 44 | 45 | var tz = Current; 46 | return TimeZoneInfo.ConvertTimeFromUtc(utc, tz); 47 | } 48 | 49 | private void Apply(PanelTimeZoneOptions options) 50 | { 51 | var id = (options.TimeZoneId ?? string.Empty).Trim(); 52 | 53 | var tz = Resolve(id); 54 | lock (_gate) 55 | { 56 | _timeZone = tz; 57 | } 58 | } 59 | 60 | private static TimeZoneInfo Resolve(string timeZoneId) 61 | { 62 | if (string.IsNullOrWhiteSpace(timeZoneId)) 63 | return TimeZoneInfo.Utc; 64 | 65 | if (TryFind(timeZoneId, out var tz)) 66 | return tz; 67 | 68 | // 常见跨平台兜底:IANA <-> Windows 69 | if (string.Equals(timeZoneId, "Asia/Shanghai", StringComparison.OrdinalIgnoreCase) 70 | && TryFind("China Standard Time", out tz)) 71 | return tz; 72 | 73 | if (string.Equals(timeZoneId, "China Standard Time", StringComparison.OrdinalIgnoreCase) 74 | && TryFind("Asia/Shanghai", out tz)) 75 | return tz; 76 | 77 | if (string.Equals(timeZoneId, "UTC", StringComparison.OrdinalIgnoreCase) && TryFind("Etc/UTC", out tz)) 78 | return tz; 79 | 80 | if (string.Equals(timeZoneId, "Etc/UTC", StringComparison.OrdinalIgnoreCase) && TryFind("UTC", out tz)) 81 | return tz; 82 | 83 | return TimeZoneInfo.Utc; 84 | } 85 | 86 | private static bool TryFind(string id, out TimeZoneInfo timeZone) 87 | { 88 | try 89 | { 90 | timeZone = TimeZoneInfo.FindSystemTimeZoneById(id); 91 | return true; 92 | } 93 | catch 94 | { 95 | timeZone = TimeZoneInfo.Utc; 96 | return false; 97 | } 98 | } 99 | } 100 | 101 | -------------------------------------------------------------------------------- /src/TelegramPanel.Web/Components/Dialogs/BotChannelCategoryDialog.razor: -------------------------------------------------------------------------------- 1 | @namespace TelegramPanel.Web.Components.Dialogs 2 | @inject BotManagementService BotManagement 3 | @inject ISnackbar Snackbar 4 | @using TelegramPanel.Data.Entities 5 | 6 | 7 | 8 | 9 | 11 | 13 | 14 | 15 | 16 | 取消 17 | 18 | @if (saving) 19 | { 20 | 21 | 保存中... 22 | } 23 | else 24 | { 25 | 保存 26 | } 27 | 28 | 29 | 30 | 31 | @code 32 | { 33 | [CascadingParameter] private MudDialogInstance MudDialog { get; set; } = default!; 34 | [Parameter] public int BotId { get; set; } 35 | [Parameter] public BotChannelCategory? Category { get; set; } 36 | 37 | private bool saving; 38 | private string name = ""; 39 | private string description = ""; 40 | 41 | protected override void OnInitialized() 42 | { 43 | // 如果传入了分类,说明是编辑模式 44 | if (Category != null) 45 | { 46 | name = Category.Name ?? ""; 47 | description = Category.Description ?? ""; 48 | } 49 | } 50 | 51 | private void Cancel() 52 | { 53 | MudDialog.Cancel(); 54 | } 55 | 56 | private async Task Save() 57 | { 58 | if (saving) 59 | return; 60 | 61 | name = (name ?? string.Empty).Trim(); 62 | if (string.IsNullOrWhiteSpace(name)) 63 | { 64 | Snackbar.Add("请输入分类名称", Severity.Warning); 65 | return; 66 | } 67 | 68 | saving = true; 69 | try 70 | { 71 | if (Category == null) 72 | { 73 | // 新增模式:使用现有的 CreateCategoryAsync 方法 74 | var category = await BotManagement.CreateCategoryAsync(BotId, name, description); 75 | Snackbar.Add("分类创建成功", Severity.Success); 76 | MudDialog.Close(DialogResult.Ok(category)); 77 | } 78 | else 79 | { 80 | // 编辑模式:需要更新方法(暂未实现) 81 | Category.Name = name; 82 | Category.Description = description; 83 | // TODO: 需要实现 UpdateCategoryAsync 方法 84 | Snackbar.Add("分类更新功能待实现", Severity.Warning); 85 | MudDialog.Close(DialogResult.Ok(Category)); 86 | } 87 | } 88 | catch (Exception ex) 89 | { 90 | Snackbar.Add($"保存失败:{ex.Message}", Severity.Error); 91 | } 92 | finally 93 | { 94 | saving = false; 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/TelegramPanel.Web/Components/Dialogs/SystemInboxDialog.razor: -------------------------------------------------------------------------------- 1 | @namespace TelegramPanel.Web.Components.Dialogs 2 | @inject AccountTelegramToolsService AccountTelegramTools 3 | @inject ISnackbar Snackbar 4 | 5 | 6 | 7 | 8 | 账号:@Phone(ID:@AccountId) 9 | 10 | 11 | 这里显示 Telegram 系统通知(通常用于接收登录验证码)。若列表为空,可能是该账号从未在 Telegram 内收到系统消息。 12 | 13 | 14 | 15 | 17 | 刷新 18 | 19 | @if (lastLoadedAtUtc.HasValue) 20 | { 21 | 最后刷新: 22 | } 23 | 24 | 25 | @if (loading) 26 | { 27 | 28 | } 29 | else if (messages.Count == 0) 30 | { 31 | 暂无系统通知 32 | } 33 | else 34 | { 35 | 36 | @foreach (var m in messages) 37 | { 38 | 39 | 40 | @m.Text 41 | 42 | } 43 | 44 | } 45 | 46 | 47 | 48 | 关闭 49 | 50 | 51 | 52 | @code 53 | { 54 | [CascadingParameter] private MudDialogInstance MudDialog { get; set; } = default!; 55 | [Parameter] public int AccountId { get; set; } 56 | [Parameter] public string Phone { get; set; } = ""; 57 | 58 | private bool loading; 59 | private DateTime? lastLoadedAtUtc; 60 | private List messages = new(); 61 | 62 | protected override async Task OnInitializedAsync() 63 | { 64 | await LoadMessages(); 65 | } 66 | 67 | private async Task LoadMessages() 68 | { 69 | loading = true; 70 | try 71 | { 72 | var list = await AccountTelegramTools.GetLatestSystemMessagesAsync(AccountId, limit: 30); 73 | messages = list.ToList(); 74 | lastLoadedAtUtc = DateTime.UtcNow; 75 | } 76 | catch (Exception ex) 77 | { 78 | Snackbar.Add($"加载系统通知失败:{ex.Message}", Severity.Error); 79 | } 80 | finally 81 | { 82 | loading = false; 83 | } 84 | } 85 | 86 | private void Close() 87 | { 88 | MudDialog.Close(); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/TelegramPanel.Core/Services/BatchTaskManagementService.cs: -------------------------------------------------------------------------------- 1 | using TelegramPanel.Data.Entities; 2 | using TelegramPanel.Data.Repositories; 3 | 4 | namespace TelegramPanel.Core.Services; 5 | 6 | /// 7 | /// 批量任务管理服务 8 | /// 9 | public class BatchTaskManagementService 10 | { 11 | private readonly IBatchTaskRepository _batchTaskRepository; 12 | 13 | public BatchTaskManagementService(IBatchTaskRepository batchTaskRepository) 14 | { 15 | _batchTaskRepository = batchTaskRepository; 16 | } 17 | 18 | public async Task GetTaskAsync(int id) 19 | { 20 | return await _batchTaskRepository.GetByIdAsync(id); 21 | } 22 | 23 | public async Task> GetAllTasksAsync() 24 | { 25 | return await _batchTaskRepository.GetAllAsync(); 26 | } 27 | 28 | public async Task> GetTasksByStatusAsync(string status) 29 | { 30 | return await _batchTaskRepository.GetByStatusAsync(status); 31 | } 32 | 33 | public async Task> GetRunningTasksAsync() 34 | { 35 | return await _batchTaskRepository.GetRunningTasksAsync(); 36 | } 37 | 38 | public async Task> GetRecentTasksAsync(int count = 20) 39 | { 40 | return await _batchTaskRepository.GetRecentTasksAsync(count); 41 | } 42 | 43 | public async Task CreateTaskAsync(BatchTask task) 44 | { 45 | task.CreatedAt = DateTime.UtcNow; 46 | task.Status = "pending"; 47 | return await _batchTaskRepository.AddAsync(task); 48 | } 49 | 50 | public async Task UpdateTaskProgressAsync(int taskId, int completed, int failed) 51 | { 52 | var task = await _batchTaskRepository.GetByIdAsync(taskId); 53 | if (task != null) 54 | { 55 | task.Completed = completed; 56 | task.Failed = failed; 57 | await _batchTaskRepository.UpdateAsync(task); 58 | } 59 | } 60 | 61 | public async Task UpdateTaskConfigAsync(int taskId, string? config) 62 | { 63 | var task = await _batchTaskRepository.GetByIdAsync(taskId); 64 | if (task != null) 65 | { 66 | task.Config = config; 67 | await _batchTaskRepository.UpdateAsync(task); 68 | } 69 | } 70 | 71 | public async Task StartTaskAsync(int taskId) 72 | { 73 | var task = await _batchTaskRepository.GetByIdAsync(taskId); 74 | if (task != null) 75 | { 76 | task.Status = "running"; 77 | task.StartedAt = DateTime.UtcNow; 78 | await _batchTaskRepository.UpdateAsync(task); 79 | } 80 | } 81 | 82 | public async Task CompleteTaskAsync(int taskId, bool success = true) 83 | { 84 | var task = await _batchTaskRepository.GetByIdAsync(taskId); 85 | if (task != null) 86 | { 87 | task.Status = success ? "completed" : "failed"; 88 | task.CompletedAt = DateTime.UtcNow; 89 | await _batchTaskRepository.UpdateAsync(task); 90 | } 91 | } 92 | 93 | public async Task DeleteTaskAsync(int id) 94 | { 95 | var task = await _batchTaskRepository.GetByIdAsync(id); 96 | if (task != null) 97 | { 98 | await _batchTaskRepository.DeleteAsync(task); 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/TelegramPanel.Web/Components/Layout/NavMenu.razor: -------------------------------------------------------------------------------- 1 | @inject TelegramPanel.Web.Modules.ModuleContributionRegistry Contributions 2 | 3 | 4 | 5 | 仪表盘 6 | 7 | 8 | 9 | 账号列表 10 | 导入账号 11 | 手机号登录 12 | 账号分类 13 | 14 | 15 | 16 | 频道列表 17 | 创建频道 18 | 频道分组 19 | 20 | 21 | 22 | 群组列表 23 | 24 | 25 | 26 | 机器人列表 27 | Bot 频道 28 | 29 | 30 | 31 | 任务中心 32 | 33 | 34 | 35 | 模块管理 36 | 37 | 38 | @if (Contributions.NavItems.Count > 0 || Contributions.Pages.Count > 0) 39 | { 40 | 41 | @foreach (var item in Contributions.NavItems 42 | .OrderBy(x => x.Definition.Group ?? "", StringComparer.OrdinalIgnoreCase) 43 | .ThenBy(x => x.Definition.Order) 44 | .ThenBy(x => x.Definition.Title, StringComparer.OrdinalIgnoreCase)) 45 | { 46 | 47 | @item.Definition.Title 48 | 49 | } 50 | 51 | @foreach (var modulePage in Contributions.Pages 52 | .OrderBy(x => x.Definition.Group ?? "", StringComparer.OrdinalIgnoreCase) 53 | .ThenBy(x => x.Definition.Order) 54 | .ThenBy(x => x.Definition.Title, StringComparer.OrdinalIgnoreCase)) 55 | { 56 | var href = $"/ext/{modulePage.Module.Id}/{modulePage.Definition.Key}"; 57 | 58 | @modulePage.Definition.Title 59 | 60 | } 61 | 62 | } 63 | 64 | 65 | API 管理 66 | 67 | 68 | 69 | 退出登录 70 | 71 | 72 | -------------------------------------------------------------------------------- /src/TelegramPanel.Web/Components/Dialogs/CreateExternalApiDialog.razor: -------------------------------------------------------------------------------- 1 | @namespace TelegramPanel.Web.Components.Dialogs 2 | @inject ISnackbar Snackbar 3 | @using System.Security.Cryptography 4 | @using TelegramPanel.Modules 5 | @using TelegramPanel.Web.ExternalApi 6 | 7 | 8 | 9 | 10 | 11 | 新建一个外部 API 配置项(可在同一个接口下创建多个配置,使用不同的 `X-API-Key` 区分)。 12 | 13 | 14 | 16 | 17 | 18 | @foreach (var t in AvailableTypes) 19 | { 20 | @t.DisplayName(@t.Route) 21 | } 22 | 23 | 24 | 25 | 26 | 27 | 28 | 取消 29 | 创建 30 | 31 | 32 | 33 | @code 34 | { 35 | [CascadingParameter] private MudDialogInstance MudDialog { get; set; } = default!; 36 | [Parameter] public IReadOnlyList AvailableTypes { get; set; } = Array.Empty(); 37 | 38 | private string name = ""; 39 | private string type = ""; 40 | private bool enabled; 41 | 42 | protected override void OnInitialized() 43 | { 44 | if (string.IsNullOrWhiteSpace(type)) 45 | type = AvailableTypes.FirstOrDefault()?.Type ?? ""; 46 | } 47 | 48 | private void Cancel() => MudDialog.Cancel(); 49 | 50 | private void Confirm() 51 | { 52 | var n = (name ?? "").Trim(); 53 | if (string.IsNullOrWhiteSpace(n)) 54 | { 55 | Snackbar.Add("API 名称不能为空", Severity.Warning); 56 | return; 57 | } 58 | 59 | if (AvailableTypes.All(x => !string.Equals(x.Type, type, StringComparison.OrdinalIgnoreCase))) 60 | { 61 | Snackbar.Add("请选择有效的 API 类型", Severity.Warning); 62 | return; 63 | } 64 | 65 | var apiKey = GenerateKey(); 66 | var def = new ExternalApiDefinition 67 | { 68 | Name = n, 69 | Type = type, 70 | Enabled = enabled, 71 | ApiKey = apiKey, 72 | Config = new System.Text.Json.Nodes.JsonObject() 73 | }; 74 | 75 | if (string.Equals(type, ExternalApiTypes.Kick, StringComparison.OrdinalIgnoreCase)) 76 | { 77 | def.Kick = new KickApiDefinition 78 | { 79 | BotId = 0, 80 | UseAllChats = true, 81 | ChatIds = new List(), 82 | PermanentBanDefault = false 83 | }; 84 | } 85 | 86 | MudDialog.Close(DialogResult.Ok(def)); 87 | } 88 | 89 | private static string GenerateKey() 90 | { 91 | var bytes = RandomNumberGenerator.GetBytes(32); 92 | var key = Convert.ToBase64String(bytes); 93 | return key.Replace('+', '-').Replace('/', '_').TrimEnd('='); 94 | } 95 | } 96 | 97 | -------------------------------------------------------------------------------- /src/TelegramPanel.Web/Components/Dialogs/ModulePageDialog.razor: -------------------------------------------------------------------------------- 1 | @namespace TelegramPanel.Web.Components.Dialogs 2 | @using Microsoft.AspNetCore.Components 3 | @using TelegramPanel.Web.Modules 4 | @inject ModuleContributionRegistry Contributions 5 | 6 | 7 | 8 | @if (!string.IsNullOrWhiteSpace(error)) 9 | { 10 | @error 11 | } 12 | else if (componentType == null) 13 | { 14 | 15 | } 16 | else 17 | { 18 | 19 | } 20 | 21 | 22 | 关闭 23 | 24 | 25 | 26 | @code 27 | { 28 | [CascadingParameter] private MudDialogInstance MudDialog { get; set; } = default!; 29 | 30 | [Parameter] public string ModuleId { get; set; } = ""; 31 | [Parameter] public string PageKey { get; set; } = ""; 32 | 33 | private string? error; 34 | private Type? componentType; 35 | private Dictionary parameters = new(); 36 | 37 | protected override void OnParametersSet() 38 | { 39 | error = null; 40 | componentType = null; 41 | parameters = new Dictionary(); 42 | 43 | var moduleId = (ModuleId ?? "").Trim(); 44 | var pageKey = (PageKey ?? "").Trim(); 45 | if (moduleId.Length == 0 || pageKey.Length == 0) 46 | { 47 | error = "参数无效"; 48 | return; 49 | } 50 | 51 | var page = Contributions.Pages.FirstOrDefault(p => 52 | string.Equals(p.Module.Id, moduleId, StringComparison.OrdinalIgnoreCase) 53 | && string.Equals(p.Definition.Key, pageKey, StringComparison.OrdinalIgnoreCase)); 54 | 55 | if (page == null) 56 | { 57 | error = $"未找到扩展页面:{moduleId}/{pageKey}"; 58 | return; 59 | } 60 | 61 | var typeName = (page.Definition.ComponentType ?? "").Trim(); 62 | if (typeName.Length == 0) 63 | { 64 | error = "模块页面未配置 componentType"; 65 | return; 66 | } 67 | 68 | var moduleTypeName = NormalizeModuleTypeName(typeName); 69 | componentType = 70 | Type.GetType(typeName, throwOnError: false, ignoreCase: false) 71 | ?? page.Module.Instance.GetType().Assembly.GetType(moduleTypeName, throwOnError: false, ignoreCase: false); 72 | 73 | if (componentType == null) 74 | { 75 | error = $"无法加载组件类型:{typeName}"; 76 | return; 77 | } 78 | 79 | parameters = new Dictionary 80 | { 81 | ["ModuleId"] = moduleId, 82 | ["PageKey"] = pageKey 83 | }; 84 | } 85 | 86 | private static string NormalizeModuleTypeName(string typeName) 87 | { 88 | typeName = (typeName ?? "").Trim(); 89 | if (typeName.Length == 0) 90 | return typeName; 91 | 92 | var comma = typeName.IndexOf(',', StringComparison.Ordinal); 93 | if (comma > 0) 94 | return typeName.Substring(0, comma).Trim(); 95 | 96 | return typeName; 97 | } 98 | 99 | private void Close() => MudDialog.Close(); 100 | } 101 | 102 | -------------------------------------------------------------------------------- /src/TelegramPanel.Core/Interfaces/IChannelService.cs: -------------------------------------------------------------------------------- 1 | using TelegramPanel.Core.Models; 2 | 3 | namespace TelegramPanel.Core.Interfaces; 4 | 5 | /// 6 | /// 频道服务接口 7 | /// 8 | public interface IChannelService 9 | { 10 | /// 11 | /// 获取账号创建的所有频道 12 | /// 13 | Task> GetOwnedChannelsAsync(int accountId); 14 | 15 | /// 16 | /// 获取账号作为创建者/管理员的频道(包含“非本系统创建”的频道) 17 | /// 18 | Task> GetAdminedChannelsAsync(int accountId); 19 | 20 | /// 21 | /// 创建新频道 22 | /// 23 | Task CreateChannelAsync(int accountId, string title, string about, bool isPublic = false); 24 | 25 | /// 26 | /// 设置频道公开/私密 27 | /// 28 | Task SetChannelVisibilityAsync(int accountId, long channelId, bool isPublic, string? username = null); 29 | 30 | /// 31 | /// 邀请用户到频道 32 | /// 33 | Task InviteUserAsync(int accountId, long channelId, string username); 34 | 35 | /// 36 | /// 批量邀请用户 37 | /// 38 | Task> BatchInviteUsersAsync(int accountId, long channelId, List usernames, int delayMs = 2000); 39 | 40 | /// 41 | /// 设置管理员 42 | /// 43 | Task SetAdminAsync(int accountId, long channelId, string username, AdminRights rights, string title = "Admin"); 44 | 45 | /// 46 | /// 批量设置管理员 47 | /// 48 | Task> BatchSetAdminsAsync(int accountId, long channelId, List requests); 49 | 50 | /// 51 | /// 设置是否允许转发(关闭后为“保护内容”,禁止转发/保存) 52 | /// 53 | Task SetForwardingAllowedAsync(int accountId, long channelId, bool allowed); 54 | 55 | /// 56 | /// 导出频道加入链接:公开频道返回 t.me 链接;私密频道导出邀请链接(需要权限)。 57 | /// 58 | Task ExportJoinLinkAsync(int accountId, long channelId); 59 | 60 | /// 61 | /// 获取频道管理员列表 62 | /// 63 | Task> GetAdminsAsync(int accountId, long channelId); 64 | 65 | /// 66 | /// 编辑频道信息(标题/简介) 67 | /// 68 | Task UpdateChannelInfoAsync(int accountId, long channelId, string title, string? about); 69 | 70 | /// 71 | /// 从频道踢出用户(通过 username),可选是否永久封禁 72 | /// 73 | Task KickUserAsync(int accountId, long channelId, string username, bool permanentBan = false); 74 | } 75 | 76 | /// 77 | /// 邀请结果 78 | /// 79 | public record InviteResult(string Username, bool Success, string? Error = null); 80 | 81 | /// 82 | /// 设置管理员结果 83 | /// 84 | public record SetAdminResult(string Username, bool Success, string? Error = null); 85 | 86 | /// 87 | /// 管理员请求 88 | /// 89 | public record AdminRequest(string Username, AdminRights Rights, string Title = "Admin"); 90 | 91 | /// 92 | /// 管理员权限 93 | /// 94 | [Flags] 95 | public enum AdminRights 96 | { 97 | None = 0, 98 | ChangeInfo = 1, 99 | PostMessages = 2, 100 | EditMessages = 4, 101 | DeleteMessages = 8, 102 | BanUsers = 16, 103 | InviteUsers = 32, 104 | PinMessages = 64, 105 | ManageCall = 128, 106 | AddAdmins = 256, 107 | Anonymous = 512, 108 | ManageTopics = 1024, 109 | 110 | // 常用组合 111 | BasicAdmin = ChangeInfo | PostMessages | EditMessages | DeleteMessages | BanUsers | InviteUsers | PinMessages, 112 | FullAdmin = BasicAdmin | ManageCall | AddAdmins 113 | } 114 | -------------------------------------------------------------------------------- /src/TelegramPanel.Web/Components/Dialogs/BotBanMemberDialog.razor: -------------------------------------------------------------------------------- 1 | @namespace TelegramPanel.Web.Components.Dialogs 2 | @using TelegramPanel.Data.Entities 3 | 4 | 5 | 6 | 7 | 8 | 将从 @SelectedCount 个频道移除指定用户 9 | 10 | 11 | 14 | 15 | 16 | 如何获取用户 ID? 17 |
    18 |
  1. 打开网页版:登录 Telegram 网页版 (web.telegram.org)
  2. 19 |
  3. 找到联系人:点击进入你想查询的用户的私聊窗口
  4. 20 |
  5. 查看链接:在浏览器地址栏中,链接最后的那串数字(如 web.telegram.org 中的 123456789)就是对方的 UserID
  6. 21 |
22 |
23 | 24 | 25 | 26 | 27 | 仅踢出 28 | 29 | (用户被移除,但可以通过邀请链接重新加入) 30 | 31 | 32 | 33 | 34 | 35 | 永久封禁 36 | 37 | (用户被封禁,无法再次加入频道) 38 | 39 | 40 | 41 | 42 |
43 |
44 | 45 | 取消 46 | 47 | @(banMode == BanMode.PermanentBan ? "永久封禁" : "踢出用户") 48 | 49 | 50 |
51 | 52 | @code 53 | { 54 | [CascadingParameter] private MudDialogInstance MudDialog { get; set; } = default!; 55 | [Parameter] public int SelectedCount { get; set; } 56 | 57 | private string userIdInput = ""; 58 | private BanMode banMode = BanMode.KickOnly; 59 | 60 | private enum BanMode 61 | { 62 | KickOnly, // 仅踢出 63 | PermanentBan // 永久封禁 64 | } 65 | 66 | private void Cancel() => MudDialog.Cancel(); 67 | 68 | private void Confirm() 69 | { 70 | var input = (userIdInput ?? string.Empty).Trim(); 71 | if (string.IsNullOrWhiteSpace(input)) 72 | { 73 | // 空输入直接取消 74 | MudDialog.Cancel(); 75 | return; 76 | } 77 | 78 | if (!long.TryParse(input, out var userId) || userId <= 0) 79 | { 80 | // 无效的用户 ID,返回错误标记 81 | MudDialog.Close(DialogResult.Cancel()); 82 | return; 83 | } 84 | 85 | // 返回用户ID和封禁模式 86 | var result = new BanUserRequest(userId, banMode == BanMode.PermanentBan); 87 | MudDialog.Close(DialogResult.Ok(result)); 88 | } 89 | 90 | public record BanUserRequest(long UserId, bool PermanentBan); 91 | } 92 | -------------------------------------------------------------------------------- /src/TelegramPanel.Web/Components/Dialogs/RiskWarningDialog.razor: -------------------------------------------------------------------------------- 1 | @namespace TelegramPanel.Web.Components.Dialogs 2 | @using TelegramPanel.Core.Services 3 | @using TelegramPanel.Data.Entities 4 | 5 | @* 账号风控警告对话框 *@ 6 | 7 | 8 | 9 | 10 | 11 | ⚠️ 风控警告 12 | 13 | 14 | @Message 15 | 16 | @if (!string.IsNullOrWhiteSpace(DetailedMessage)) 17 | { 18 | 19 | @DetailedMessage 20 | 21 | } 22 | 23 | 24 | @if (ShowRecommendations) 25 | { 26 | 27 | 建议操作: 28 | 29 | 30 | 31 | 等待满 24 小时后再进行敏感操作 32 | 33 | 34 | 降低操作频率,避免被 Telegram 风控系统标记 35 | 36 | 37 | 优先使用低风险操作替代 38 | 39 | 40 | } 41 | 42 | @if (RiskyAccounts != null && RiskyAccounts.Any()) 43 | { 44 | 45 | 风险账号列表: 46 | 47 | 48 | @foreach (var account in RiskyAccounts) 49 | { 50 | 51 | @account.Phone 52 | @if (account.LastLoginAt != null) 53 | { 54 | (登录 @account.GetLoginHoursFormatted() 小时) 55 | } 56 | 57 | } 58 | 59 | } 60 | 61 | 62 | 63 | 取消操作 64 | 65 | @if (ShowExcludeOption && RiskyAccounts != null && RiskyAccounts.Any()) 66 | { 67 | 68 | 排除风险账号后继续 69 | 70 | } 71 | 72 | 我了解风险,继续操作 73 | 74 | 75 | 76 | 77 | @code { 78 | [CascadingParameter] 79 | MudDialogInstance? MudDialog { get; set; } 80 | 81 | [Parameter] 82 | public string Message { get; set; } = "存在风控风险"; 83 | 84 | [Parameter] 85 | public string? DetailedMessage { get; set; } 86 | 87 | [Parameter] 88 | public bool ShowRecommendations { get; set; } = true; 89 | 90 | [Parameter] 91 | public bool ShowExcludeOption { get; set; } = false; 92 | 93 | [Parameter] 94 | public List? RiskyAccounts { get; set; } 95 | 96 | private void Cancel() 97 | { 98 | MudDialog?.Close(DialogResult.Cancel()); 99 | } 100 | 101 | private void Continue() 102 | { 103 | MudDialog?.Close(DialogResult.Ok(RiskWarningAction.Continue)); 104 | } 105 | 106 | private void ExcludeRisky() 107 | { 108 | MudDialog?.Close(DialogResult.Ok(RiskWarningAction.ExcludeRisky)); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/TelegramPanel.Web/Components/Pages/AdminPassword.razor: -------------------------------------------------------------------------------- 1 | @page "/admin/password" 2 | @inject TelegramPanel.Web.Services.AdminCredentialStore CredentialStore 3 | @inject ISnackbar Snackbar 4 | @inject NavigationManager Navigation 5 | @using TelegramPanel.Web.Services 6 | 7 | 修改密码 - Telegram Panel 8 | 9 | 修改后台密码 10 | 11 | 12 | 13 | @if (!CredentialStore.Enabled) 14 | { 15 | 16 | 后台验证未启用 17 | 18 | } 19 | else 20 | { 21 | @if (CredentialStore.MustChangePassword) 22 | { 23 | 24 | 当前仍为初始密码,为安全起见请立即修改。 25 | 26 | } 27 | 28 | 当前账号:@CredentialStore.Username 29 | 30 | 31 | 32 | 33 | } 34 | 35 | 36 | 37 | @if (saving) 38 | { 39 | 40 | 保存中... 41 | } 42 | else 43 | { 44 | 保存 45 | } 46 | 47 | 48 | 49 | 50 | @code 51 | { 52 | [SupplyParameterFromQuery(Name = "returnUrl")] 53 | public string? ReturnUrl { get; set; } 54 | 55 | private string currentPassword = ""; 56 | private string newPassword = ""; 57 | private string confirmPassword = ""; 58 | private bool saving; 59 | 60 | protected override async Task OnInitializedAsync() 61 | { 62 | await CredentialStore.EnsureInitializedAsync(); 63 | } 64 | 65 | private async Task Save() 66 | { 67 | if (!CredentialStore.Enabled) 68 | { 69 | Snackbar.Add("后台验证未启用", Severity.Warning); 70 | return; 71 | } 72 | 73 | if (saving) 74 | return; 75 | 76 | if (string.IsNullOrWhiteSpace(currentPassword)) 77 | { 78 | Snackbar.Add("请输入当前密码", Severity.Warning); 79 | return; 80 | } 81 | 82 | if (string.IsNullOrWhiteSpace(newPassword) || newPassword.Trim().Length < 6) 83 | { 84 | Snackbar.Add("新密码至少 6 位", Severity.Warning); 85 | return; 86 | } 87 | 88 | if (!string.Equals(newPassword, confirmPassword, StringComparison.Ordinal)) 89 | { 90 | Snackbar.Add("两次输入的新密码不一致", Severity.Warning); 91 | return; 92 | } 93 | 94 | saving = true; 95 | try 96 | { 97 | await CredentialStore.ChangePasswordAsync(currentPassword, newPassword); 98 | currentPassword = ""; 99 | newPassword = ""; 100 | confirmPassword = ""; 101 | Snackbar.Add("密码已更新", Severity.Success); 102 | 103 | var target = AdminAuthHelpers.IsLocalReturnUrl(ReturnUrl) ? ReturnUrl! : "/"; 104 | Navigation.NavigateTo(target); 105 | } 106 | catch (Exception ex) 107 | { 108 | Snackbar.Add($"保存失败:{ex.Message}", Severity.Error); 109 | } 110 | finally 111 | { 112 | saving = false; 113 | } 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/TelegramPanel.Modules.Abstractions/ModuleContributions.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | 3 | namespace TelegramPanel.Modules; 4 | 5 | public interface IModuleUiProvider 6 | { 7 | IEnumerable GetNavItems(ModuleHostContext context); 8 | IEnumerable GetPages(ModuleHostContext context); 9 | } 10 | 11 | public sealed class ModuleNavItem 12 | { 13 | [JsonPropertyName("title")] 14 | public string Title { get; set; } = ""; 15 | 16 | [JsonPropertyName("href")] 17 | public string Href { get; set; } = ""; 18 | 19 | [JsonPropertyName("icon")] 20 | public string Icon { get; set; } = ""; 21 | 22 | [JsonPropertyName("group")] 23 | public string? Group { get; set; } 24 | 25 | [JsonPropertyName("order")] 26 | public int Order { get; set; } = 0; 27 | } 28 | 29 | public sealed class ModulePageDefinition 30 | { 31 | [JsonPropertyName("key")] 32 | public string Key { get; set; } = ""; 33 | 34 | [JsonPropertyName("title")] 35 | public string Title { get; set; } = ""; 36 | 37 | [JsonPropertyName("icon")] 38 | public string Icon { get; set; } = ""; 39 | 40 | /// 41 | /// 组件类型的 AssemblyQualifiedName(用于 DynamicComponent 渲染)。 42 | /// 43 | [JsonPropertyName("componentType")] 44 | public string ComponentType { get; set; } = ""; 45 | 46 | [JsonPropertyName("group")] 47 | public string? Group { get; set; } 48 | 49 | [JsonPropertyName("order")] 50 | public int Order { get; set; } = 0; 51 | } 52 | 53 | public interface IModuleTaskProvider 54 | { 55 | IEnumerable GetTasks(ModuleHostContext context); 56 | } 57 | 58 | public sealed class ModuleTaskDefinition 59 | { 60 | /// 61 | /// 任务分类:例如 user / bot / system(建议使用小写)。 62 | /// 63 | [JsonPropertyName("category")] 64 | public string Category { get; set; } = ""; 65 | 66 | /// 67 | /// 任务类型常量(数据库 BatchTask.TaskType)。 68 | /// 69 | [JsonPropertyName("taskType")] 70 | public string TaskType { get; set; } = ""; 71 | 72 | [JsonPropertyName("displayName")] 73 | public string DisplayName { get; set; } = ""; 74 | 75 | [JsonPropertyName("description")] 76 | public string? Description { get; set; } 77 | 78 | [JsonPropertyName("icon")] 79 | public string Icon { get; set; } = ""; 80 | 81 | /// 82 | /// 如果提供了创建页面路由,则任务中心“新建任务”会跳转到该页面创建。 83 | /// 84 | [JsonPropertyName("createRoute")] 85 | public string? CreateRoute { get; set; } 86 | 87 | /// 88 | /// 任务创建编辑器组件类型 AssemblyQualifiedName(可选)。\n /// 该组件需要支持参数:\n /// - Draft (ModuleTaskDraft)\n /// - DraftChanged (EventCallback<ModuleTaskDraft>)\n /// 89 | [JsonPropertyName("editorComponentType")] 90 | public string? EditorComponentType { get; set; } 91 | 92 | [JsonPropertyName("order")] 93 | public int Order { get; set; } = 0; 94 | } 95 | 96 | public readonly record struct ModuleTaskDraft(int Total, string? Config, bool CanSubmit, string? ValidationError); 97 | 98 | public interface IModuleApiProvider 99 | { 100 | IEnumerable GetApis(ModuleHostContext context); 101 | } 102 | 103 | public sealed class ModuleApiTypeDefinition 104 | { 105 | [JsonPropertyName("type")] 106 | public string Type { get; set; } = ""; 107 | 108 | [JsonPropertyName("displayName")] 109 | public string DisplayName { get; set; } = ""; 110 | 111 | [JsonPropertyName("route")] 112 | public string Route { get; set; } = ""; 113 | 114 | [JsonPropertyName("description")] 115 | public string? Description { get; set; } 116 | 117 | [JsonPropertyName("order")] 118 | public int Order { get; set; } = 0; 119 | } 120 | 121 | public interface IModuleTaskHandler 122 | { 123 | string TaskType { get; } 124 | Task ExecuteAsync(IModuleTaskExecutionHost host, CancellationToken cancellationToken); 125 | } 126 | 127 | public interface IModuleTaskExecutionHost 128 | { 129 | int TaskId { get; } 130 | string TaskType { get; } 131 | int Total { get; } 132 | string? Config { get; } 133 | 134 | IServiceProvider Services { get; } 135 | 136 | Task IsStillRunningAsync(CancellationToken cancellationToken); 137 | Task UpdateProgressAsync(int completed, int failed, CancellationToken cancellationToken); 138 | } 139 | 140 | -------------------------------------------------------------------------------- /src/TelegramPanel.Modules.Abstractions/SemVer.cs: -------------------------------------------------------------------------------- 1 | namespace TelegramPanel.Modules; 2 | 3 | public readonly record struct SemVer(int Major, int Minor, int Patch) : IComparable 4 | { 5 | public static bool TryParse(string? value, out SemVer ver) 6 | { 7 | ver = default; 8 | value = (value ?? string.Empty).Trim(); 9 | if (value.Length == 0) 10 | return false; 11 | 12 | // 只支持 x.y.z,忽略预发布/构建元数据 13 | var parts = value.Split('.', StringSplitOptions.RemoveEmptyEntries); 14 | if (parts.Length < 1 || parts.Length > 3) 15 | return false; 16 | 17 | if (!int.TryParse(parts[0], out var major)) 18 | return false; 19 | var minor = 0; 20 | var patch = 0; 21 | if (parts.Length >= 2 && !int.TryParse(parts[1], out minor)) 22 | return false; 23 | if (parts.Length >= 3 && !int.TryParse(parts[2], out patch)) 24 | return false; 25 | 26 | ver = new SemVer(major, minor, patch); 27 | return true; 28 | } 29 | 30 | public int CompareTo(SemVer other) 31 | { 32 | var c = Major.CompareTo(other.Major); 33 | if (c != 0) return c; 34 | c = Minor.CompareTo(other.Minor); 35 | if (c != 0) return c; 36 | return Patch.CompareTo(other.Patch); 37 | } 38 | 39 | public override string ToString() => $"{Major}.{Minor}.{Patch}"; 40 | } 41 | 42 | public sealed class VersionRange 43 | { 44 | private readonly List> _predicates = new(); 45 | 46 | public static bool TryParse(string? expression, out VersionRange range, out string error) 47 | { 48 | range = new VersionRange(); 49 | error = ""; 50 | 51 | expression = (expression ?? string.Empty).Trim(); 52 | if (expression.Length == 0) 53 | { 54 | error = "range 为空"; 55 | return false; 56 | } 57 | 58 | var parts = expression.Split(' ', StringSplitOptions.RemoveEmptyEntries); 59 | foreach (var raw in parts) 60 | { 61 | var token = raw.Trim(); 62 | if (token.Length == 0) 63 | continue; 64 | 65 | if (SemVer.TryParse(token, out var exact)) 66 | { 67 | range._predicates.Add(v => v == exact); 68 | continue; 69 | } 70 | 71 | if (token.StartsWith(">=", StringComparison.Ordinal)) 72 | { 73 | if (!SemVer.TryParse(token.Substring(2), out var min)) 74 | { 75 | error = $"无法解析版本:{token}"; 76 | return false; 77 | } 78 | 79 | range._predicates.Add(v => v.CompareTo(min) >= 0); 80 | continue; 81 | } 82 | 83 | if (token.StartsWith(">", StringComparison.Ordinal)) 84 | { 85 | if (!SemVer.TryParse(token.Substring(1), out var min)) 86 | { 87 | error = $"无法解析版本:{token}"; 88 | return false; 89 | } 90 | 91 | range._predicates.Add(v => v.CompareTo(min) > 0); 92 | continue; 93 | } 94 | 95 | if (token.StartsWith("<=", StringComparison.Ordinal)) 96 | { 97 | if (!SemVer.TryParse(token.Substring(2), out var max)) 98 | { 99 | error = $"无法解析版本:{token}"; 100 | return false; 101 | } 102 | 103 | range._predicates.Add(v => v.CompareTo(max) <= 0); 104 | continue; 105 | } 106 | 107 | if (token.StartsWith("<", StringComparison.Ordinal)) 108 | { 109 | if (!SemVer.TryParse(token.Substring(1), out var max)) 110 | { 111 | error = $"无法解析版本:{token}"; 112 | return false; 113 | } 114 | 115 | range._predicates.Add(v => v.CompareTo(max) < 0); 116 | continue; 117 | } 118 | 119 | error = $"不支持的 range token:{token}"; 120 | return false; 121 | } 122 | 123 | if (range._predicates.Count == 0) 124 | { 125 | error = "range 无有效条件"; 126 | return false; 127 | } 128 | 129 | return true; 130 | } 131 | 132 | public bool Contains(SemVer version) 133 | { 134 | foreach (var p in _predicates) 135 | { 136 | if (!p(version)) 137 | return false; 138 | } 139 | 140 | return true; 141 | } 142 | } 143 | 144 | -------------------------------------------------------------------------------- /src/TelegramPanel.Web/Components/Dialogs/EditGenericApiDialog.razor: -------------------------------------------------------------------------------- 1 | @namespace TelegramPanel.Web.Components.Dialogs 2 | @inject ISnackbar Snackbar 3 | @using System.Security.Cryptography 4 | @using System.Text.Json 5 | @using System.Text.Json.Nodes 6 | @using TelegramPanel.Web.ExternalApi 7 | 8 | 9 | 10 | 11 | 12 | 外部接口:@(string.IsNullOrWhiteSpace(Route) ? "-" : Route)(Type: @(string.IsNullOrWhiteSpace(Api.Type) ? "-" : Api.Type)) 13 | 14 | 15 | 16 | 17 | 18 | 19 | 21 | 22 | 重新生成 Key 23 | 24 | 25 | 27 | 28 | 29 | 30 | 取消 31 | 32 | @if (saving) 33 | { 34 | 35 | 保存中... 36 | } 37 | else 38 | { 39 | 保存 40 | } 41 | 42 | 43 | 44 | 45 | @code 46 | { 47 | [CascadingParameter] private MudDialogInstance MudDialog { get; set; } = default!; 48 | 49 | [Parameter] public ExternalApiDefinition Api { get; set; } = new(); 50 | [Parameter] public string? Route { get; set; } 51 | 52 | private bool saving; 53 | private ExternalApiDefinition model = new(); 54 | private string configJson = "{}"; 55 | 56 | protected override void OnInitialized() 57 | { 58 | model = Clone(Api); 59 | var cfg = model.Config ?? new JsonObject(); 60 | configJson = cfg.ToJsonString(new JsonSerializerOptions { WriteIndented = true }); 61 | } 62 | 63 | private void Cancel() => MudDialog.Cancel(); 64 | 65 | private void RegenerateKey() 66 | { 67 | var bytes = RandomNumberGenerator.GetBytes(32); 68 | var key = Convert.ToBase64String(bytes).Replace('+', '-').Replace('/', '_').TrimEnd('='); 69 | model.ApiKey = key; 70 | Snackbar.Add("已重新生成 Key(记得保存)", Severity.Info); 71 | } 72 | 73 | private void Save() 74 | { 75 | if (saving) 76 | return; 77 | 78 | var n = (model.Name ?? "").Trim(); 79 | if (string.IsNullOrWhiteSpace(n)) 80 | { 81 | Snackbar.Add("API 名称不能为空", Severity.Warning); 82 | return; 83 | } 84 | 85 | var key = (model.ApiKey ?? "").Trim(); 86 | if (string.IsNullOrWhiteSpace(key)) 87 | { 88 | Snackbar.Add("X-API-Key 不能为空", Severity.Warning); 89 | return; 90 | } 91 | 92 | try 93 | { 94 | var parsed = JsonNode.Parse(configJson ?? "{}"); 95 | if (parsed is not JsonObject obj) 96 | { 97 | Snackbar.Add("Config 必须是 JSON 对象(例如 {})", Severity.Warning); 98 | return; 99 | } 100 | 101 | model.Name = n; 102 | model.ApiKey = key; 103 | model.Config = obj; 104 | } 105 | catch (Exception ex) 106 | { 107 | Snackbar.Add($"Config JSON 无效:{ex.Message}", Severity.Warning); 108 | return; 109 | } 110 | 111 | MudDialog.Close(DialogResult.Ok(model)); 112 | } 113 | 114 | private static ExternalApiDefinition Clone(ExternalApiDefinition src) 115 | { 116 | // 仅用于编辑器内复制(避免直接改引用) 117 | return new ExternalApiDefinition 118 | { 119 | Id = src.Id, 120 | Name = src.Name, 121 | Type = src.Type, 122 | Enabled = src.Enabled, 123 | ApiKey = src.ApiKey, 124 | Kick = src.Kick, 125 | Config = src.Config ?? new JsonObject() 126 | }; 127 | } 128 | } 129 | 130 | -------------------------------------------------------------------------------- /src/TelegramPanel.Web/Services/DataSyncService.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Logging; 2 | using TelegramPanel.Core.Interfaces; 3 | using TelegramPanel.Core.Services; 4 | using TelegramPanel.Data.Entities; 5 | 6 | namespace TelegramPanel.Web.Services; 7 | 8 | /// 9 | /// 数据同步服务(页面层复用:Home/Settings/Channels) 10 | /// 11 | public class DataSyncService 12 | { 13 | private readonly AccountManagementService _accountManagement; 14 | private readonly ChannelManagementService _channelManagement; 15 | private readonly GroupManagementService _groupManagement; 16 | private readonly IChannelService _channelService; 17 | private readonly IGroupService _groupService; 18 | private readonly ILogger _logger; 19 | 20 | public DataSyncService( 21 | AccountManagementService accountManagement, 22 | ChannelManagementService channelManagement, 23 | GroupManagementService groupManagement, 24 | IChannelService channelService, 25 | IGroupService groupService, 26 | ILogger logger) 27 | { 28 | _accountManagement = accountManagement; 29 | _channelManagement = channelManagement; 30 | _groupManagement = groupManagement; 31 | _channelService = channelService; 32 | _groupService = groupService; 33 | _logger = logger; 34 | } 35 | 36 | public async Task SyncAllActiveAccountsAsync(CancellationToken cancellationToken) 37 | { 38 | var accounts = await _accountManagement.GetActiveAccountsAsync(); 39 | return await SyncAccountsAsync(accounts, cancellationToken); 40 | } 41 | 42 | public async Task SyncAccountAsync(int accountId, CancellationToken cancellationToken) 43 | { 44 | var account = await _accountManagement.GetAccountAsync(accountId) 45 | ?? throw new InvalidOperationException($"账号不存在:{accountId}"); 46 | 47 | return await SyncAccountsAsync(new[] { account }, cancellationToken); 48 | } 49 | 50 | public async Task SyncAccountsAsync(IEnumerable accounts, CancellationToken cancellationToken) 51 | { 52 | var summary = new SyncSummary(); 53 | 54 | foreach (var account in accounts) 55 | { 56 | cancellationToken.ThrowIfCancellationRequested(); 57 | 58 | try 59 | { 60 | // 同步频道:仅同步“频道创建人=本账号”的频道 61 | var channelInfos = await _channelService.GetOwnedChannelsAsync(account.Id); 62 | var keepChannelIds = new List(capacity: channelInfos.Count); 63 | 64 | foreach (var channelInfo in channelInfos) 65 | { 66 | cancellationToken.ThrowIfCancellationRequested(); 67 | 68 | var channel = new TelegramPanel.Data.Entities.Channel 69 | { 70 | TelegramId = channelInfo.TelegramId, 71 | AccessHash = channelInfo.AccessHash, 72 | Title = channelInfo.Title, 73 | Username = channelInfo.Username, 74 | IsBroadcast = channelInfo.IsBroadcast, 75 | MemberCount = channelInfo.MemberCount, 76 | About = channelInfo.About, 77 | CreatorAccountId = account.Id, 78 | CreatedAt = channelInfo.CreatedAt 79 | }; 80 | 81 | var saved = await _channelManagement.CreateOrUpdateChannelAsync(channel); 82 | keepChannelIds.Add(saved.Id); 83 | 84 | summary.TotalChannelsSynced++; 85 | } 86 | 87 | // 同步群组:保持原逻辑(仅创建的群组) 88 | var groups = await _groupService.GetOwnedGroupsAsync(account.Id); 89 | foreach (var groupInfo in groups) 90 | { 91 | cancellationToken.ThrowIfCancellationRequested(); 92 | 93 | var group = new TelegramPanel.Data.Entities.Group 94 | { 95 | TelegramId = groupInfo.TelegramId, 96 | AccessHash = groupInfo.AccessHash, 97 | Title = groupInfo.Title, 98 | Username = groupInfo.Username, 99 | MemberCount = groupInfo.MemberCount, 100 | About = null, 101 | CreatorAccountId = account.Id 102 | }; 103 | 104 | await _groupManagement.CreateOrUpdateGroupAsync(group); 105 | summary.TotalGroupsSynced++; 106 | } 107 | 108 | await _accountManagement.UpdateLastSyncTimeAsync(account.Id); 109 | } 110 | catch (Exception ex) 111 | { 112 | _logger.LogWarning(ex, "Account sync failed: {AccountId}", account.Id); 113 | summary.AccountFailures.Add((account.Id, account.Phone, ex.Message)); 114 | } 115 | } 116 | 117 | return summary; 118 | } 119 | 120 | public sealed class SyncSummary 121 | { 122 | public int TotalChannelsSynced { get; set; } 123 | public int TotalGroupsSynced { get; set; } 124 | public List<(int AccountId, string Phone, string Error)> AccountFailures { get; } = new(); 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/TelegramPanel.Web/Components/Dialogs/ChannelEditDialog.razor: -------------------------------------------------------------------------------- 1 | @namespace TelegramPanel.Web.Components.Dialogs 2 | @inject ChannelManagementService ChannelManagement 3 | @inject IChannelService ChannelService 4 | @inject ISnackbar Snackbar 5 | @using TelegramPanel.Data.Entities 6 | 7 | 8 | 9 | @if (loading) 10 | { 11 | 12 | } 13 | else if (channel == null) 14 | { 15 | 未找到频道数据 16 | } 17 | else 18 | { 19 | 20 | 21 | 22 | 23 | 24 | @if (isPublic) 25 | { 26 | 28 | } 29 | 30 | 32 | 33 | } 34 | 35 | 36 | 取消 37 | 38 | @if (saving) 39 | { 40 | 41 | 保存中... 42 | } 43 | else 44 | { 45 | 保存 46 | } 47 | 48 | 49 | 50 | 51 | @code 52 | { 53 | [CascadingParameter] private MudDialogInstance MudDialog { get; set; } = default!; 54 | [Parameter] public int ChannelDbId { get; set; } 55 | 56 | private bool loading = true; 57 | private bool saving; 58 | private Channel? channel; 59 | private int? executeAccountId; 60 | 61 | private string title = ""; 62 | private string? about; 63 | private bool isPublic; 64 | private string username = ""; 65 | private bool forwardingAllowed = true; 66 | 67 | private bool CanSave => channel != null && !string.IsNullOrWhiteSpace(title) && (!isPublic || !string.IsNullOrWhiteSpace(username)); 68 | 69 | protected override async Task OnInitializedAsync() 70 | { 71 | await LoadChannel(); 72 | } 73 | 74 | private async Task LoadChannel() 75 | { 76 | loading = true; 77 | try 78 | { 79 | channel = await ChannelManagement.GetChannelAsync(ChannelDbId); 80 | if (channel == null) 81 | return; 82 | 83 | title = channel.Title; 84 | about = channel.About; 85 | isPublic = !string.IsNullOrWhiteSpace(channel.Username); 86 | username = channel.Username ?? ""; 87 | 88 | executeAccountId = await ChannelManagement.ResolveExecuteAccountIdAsync(channel); 89 | } 90 | catch (Exception ex) 91 | { 92 | Snackbar.Add($"加载频道失败:{ex.Message}", Severity.Error); 93 | } 94 | finally 95 | { 96 | loading = false; 97 | } 98 | } 99 | 100 | private async Task Save() 101 | { 102 | if (channel == null) 103 | return; 104 | 105 | if (executeAccountId is not > 0) 106 | { 107 | Snackbar.Add("该频道暂无可用的执行账号(请先同步频道关联账号,或选择有管理员权限的账号)", Severity.Warning); 108 | return; 109 | } 110 | 111 | saving = true; 112 | try 113 | { 114 | var newTitle = title.Trim(); 115 | var newAbout = string.IsNullOrWhiteSpace(about) ? null : about.Trim(); 116 | var newUsername = string.IsNullOrWhiteSpace(username) ? null : username.Trim().TrimStart('@'); 117 | 118 | await ChannelService.UpdateChannelInfoAsync(executeAccountId.Value, channel.TelegramId, newTitle, newAbout); 119 | await ChannelService.SetChannelVisibilityAsync(executeAccountId.Value, channel.TelegramId, isPublic, newUsername); 120 | await ChannelService.SetForwardingAllowedAsync(executeAccountId.Value, channel.TelegramId, forwardingAllowed); 121 | 122 | channel.Title = newTitle; 123 | channel.About = newAbout; 124 | channel.Username = isPublic ? newUsername : null; 125 | 126 | await ChannelManagement.UpdateChannelAsync(channel); 127 | 128 | Snackbar.Add("保存成功", Severity.Success); 129 | MudDialog.Close(DialogResult.Ok(true)); 130 | } 131 | catch (Exception ex) 132 | { 133 | Snackbar.Add($"保存失败:{ex.Message}", Severity.Error); 134 | } 135 | finally 136 | { 137 | saving = false; 138 | } 139 | } 140 | 141 | private void Cancel() 142 | { 143 | MudDialog.Cancel(); 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /src/TelegramPanel.Core/Services/AccountRiskService.cs: -------------------------------------------------------------------------------- 1 | using TelegramPanel.Data.Entities; 2 | 3 | namespace TelegramPanel.Core.Services; 4 | 5 | /// 6 | /// 账号风控检查服务 7 | /// 8 | public class AccountRiskService 9 | { 10 | /// 11 | /// 风控阈值:登录后需满 24 小时才能进行敏感操作 12 | /// 13 | private const double RISK_THRESHOLD_HOURS = 24.0; 14 | 15 | /// 16 | /// 检查单个账号的登录时长是否满足风控要求 17 | /// 18 | public RiskCheckResult CheckLoginDuration(Account account) 19 | { 20 | var referenceAt = account.GetRiskReferenceAtUtc(); 21 | var loginHours = account.GetRiskReferenceHours(); 22 | 23 | if (referenceAt == null || loginHours == null) 24 | { 25 | // 兜底:完全缺少可参考的时间,视为高风险 26 | return new RiskCheckResult 27 | { 28 | IsRisky = true, 29 | Message = "账号未记录登录时间", 30 | DetailedMessage = "该账号尚未记录登录时间(可能是刚导入的新账号),建议等待 24 小时后再进行敏感操作" 31 | }; 32 | } 33 | 34 | // 兼容历史数据:LastLoginAt 为空时,使用导入时间(CreatedAt)作为估算,避免永久误报 35 | var isEstimated = account.IsRiskReferenceEstimated(); 36 | if (isEstimated && loginHours >= RISK_THRESHOLD_HOURS) 37 | { 38 | return new RiskCheckResult 39 | { 40 | IsRisky = false, 41 | Message = "账号登录时长已满足要求(按导入时间估算)", 42 | DetailedMessage = $"当前已导入时长:{loginHours.Value:F1} 小时" 43 | }; 44 | } 45 | 46 | if (loginHours.Value < RISK_THRESHOLD_HOURS) 47 | { 48 | var remainingHours = RISK_THRESHOLD_HOURS - loginHours.Value; 49 | return new RiskCheckResult 50 | { 51 | IsRisky = true, 52 | Message = isEstimated 53 | ? $"账号未记录登录时间(按导入时间估算:{loginHours.Value:F1} 小时)" 54 | : $"账号登录时长不足 24 小时(当前:{loginHours.Value:F1} 小时)", 55 | DetailedMessage = isEstimated 56 | ? $"该账号尚未记录登录时间(可能是较早导入的账号),已按导入时间估算;建议等待 {remainingHours:F1} 小时后再进行敏感操作,以降低被 Telegram 风控的风险" 57 | : $"建议等待 {remainingHours:F1} 小时后再进行敏感操作,以降低被 Telegram 风控的风险" 58 | }; 59 | } 60 | 61 | return new RiskCheckResult 62 | { 63 | IsRisky = false, 64 | Message = "账号登录时长已满足要求", 65 | DetailedMessage = $"当前登录时长:{loginHours.Value:F1} 小时" 66 | }; 67 | } 68 | 69 | /// 70 | /// 批量检查账号的登录时长 71 | /// 72 | public BatchRiskCheckResult CheckBatchAccounts(IEnumerable accounts) 73 | { 74 | var accountList = accounts.ToList(); 75 | var riskyAccounts = new List(); 76 | var safeAccounts = new List(); 77 | 78 | foreach (var account in accountList) 79 | { 80 | var check = CheckLoginDuration(account); 81 | if (check.IsRisky) 82 | riskyAccounts.Add(account); 83 | else 84 | safeAccounts.Add(account); 85 | } 86 | 87 | return new BatchRiskCheckResult 88 | { 89 | TotalCount = accountList.Count, 90 | RiskyCount = riskyAccounts.Count, 91 | SafeCount = safeAccounts.Count, 92 | RiskyAccounts = riskyAccounts, 93 | SafeAccounts = safeAccounts, 94 | HasRiskyAccounts = riskyAccounts.Count > 0 95 | }; 96 | } 97 | } 98 | 99 | /// 100 | /// 单账号风控检查结果 101 | /// 102 | public class RiskCheckResult 103 | { 104 | /// 105 | /// 是否存在风险 106 | /// 107 | public bool IsRisky { get; set; } 108 | 109 | /// 110 | /// 风险消息 111 | /// 112 | public string Message { get; set; } = string.Empty; 113 | 114 | /// 115 | /// 详细说明 116 | /// 117 | public string DetailedMessage { get; set; } = string.Empty; 118 | } 119 | 120 | /// 121 | /// 批量账号风控检查结果 122 | /// 123 | public class BatchRiskCheckResult 124 | { 125 | /// 126 | /// 总账号数 127 | /// 128 | public int TotalCount { get; set; } 129 | 130 | /// 131 | /// 风险账号数 132 | /// 133 | public int RiskyCount { get; set; } 134 | 135 | /// 136 | /// 安全账号数 137 | /// 138 | public int SafeCount { get; set; } 139 | 140 | /// 141 | /// 风险账号列表 142 | /// 143 | public List RiskyAccounts { get; set; } = new(); 144 | 145 | /// 146 | /// 安全账号列表 147 | /// 148 | public List SafeAccounts { get; set; } = new(); 149 | 150 | /// 151 | /// 是否存在风险账号 152 | /// 153 | public bool HasRiskyAccounts { get; set; } 154 | 155 | /// 156 | /// 获取风险摘要信息 157 | /// 158 | public string GetRiskySummary() 159 | { 160 | if (RiskyCount == 0) 161 | return "所有账号均已满足登录时长要求"; 162 | 163 | var riskyPhones = string.Join("、", RiskyAccounts.Select(a => 164 | { 165 | var hours = a.GetRiskReferenceHours(); 166 | if (!hours.HasValue) 167 | return a.Phone; 168 | 169 | var tag = a.IsRiskReferenceEstimated() ? "导入" : "登录"; 170 | return $"{a.Phone}({tag}{hours.Value:F1}h)"; 171 | })); 172 | 173 | return $"以下账号登录时长不足 24 小时:{riskyPhones}"; 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /src/TelegramPanel.Web/Components/Dialogs/BotChannelEditDialog.razor: -------------------------------------------------------------------------------- 1 | @namespace TelegramPanel.Web.Components.Dialogs 2 | @inject BotManagementService BotManagement 3 | @inject TelegramPanel.Core.Services.Telegram.BotTelegramService BotTelegram 4 | @inject ISnackbar Snackbar 5 | @using TelegramPanel.Data.Entities 6 | @using Microsoft.AspNetCore.Components.Forms 7 | 8 | 9 | 10 | @if (loading) 11 | { 12 | 13 | } 14 | else 15 | { 16 | 17 | 18 | 19 | 20 | 21 | @if (editAvatar) 22 | { 23 | 24 | 25 | 26 | 选择头像图片 27 | 28 | 29 | 30 | 31 | @if (avatarFile != null) 32 | { 33 | 已选择:@avatarFile.Name(@avatarFile.Size bytes) 34 | } 35 | } 36 | 37 | } 38 | 39 | 40 | 取消 41 | 42 | @if (saving) 43 | { 44 | 45 | 保存中... 46 | } 47 | else 48 | { 49 | 保存 50 | } 51 | 52 | 53 | 54 | 55 | @code 56 | { 57 | [CascadingParameter] private MudDialogInstance MudDialog { get; set; } = default!; 58 | [Parameter] public int BotId { get; set; } 59 | [Parameter] public long ChannelTelegramId { get; set; } 60 | 61 | private bool loading = true; 62 | private bool saving; 63 | 64 | private string title = ""; 65 | private string about = ""; 66 | private TelegramPanel.Core.Services.Telegram.BotTelegramService.BotChatInfo? chatInfo; 67 | private bool editAvatar; 68 | private IBrowserFile? avatarFile; 69 | 70 | protected override async Task OnInitializedAsync() 71 | { 72 | await Load(); 73 | } 74 | 75 | private async Task Load() 76 | { 77 | loading = true; 78 | try 79 | { 80 | chatInfo = await BotTelegram.GetChatInfoAsync(BotId, ChannelTelegramId, CancellationToken.None); 81 | title = chatInfo.Title ?? ""; 82 | about = chatInfo.Description ?? ""; 83 | } 84 | catch (Exception ex) 85 | { 86 | Snackbar.Add($"加载失败:{ex.Message}", Severity.Error); 87 | } 88 | finally 89 | { 90 | loading = false; 91 | } 92 | } 93 | 94 | private async Task Save() 95 | { 96 | if (saving) 97 | return; 98 | 99 | title = (title ?? string.Empty).Trim(); 100 | if (string.IsNullOrWhiteSpace(title)) 101 | { 102 | Snackbar.Add("频道标题不能为空", Severity.Warning); 103 | return; 104 | } 105 | 106 | saving = true; 107 | try 108 | { 109 | var normalizedAbout = string.IsNullOrWhiteSpace(about) ? null : about.Trim(); 110 | await BotTelegram.UpdateChannelInfoAsync(BotId, ChannelTelegramId, title, normalizedAbout, CancellationToken.None); 111 | 112 | if (editAvatar) 113 | { 114 | if (avatarFile == null) 115 | throw new InvalidOperationException("请先选择头像图片"); 116 | 117 | const long maxBytes = 20 * 1024 * 1024; 118 | await using var stream = avatarFile.OpenReadStream(maxBytes); 119 | await BotTelegram.SetChannelPhotoAsync(BotId, ChannelTelegramId, stream, avatarFile.Name, CancellationToken.None); 120 | } 121 | 122 | // 同步回本地 DB(用于列表展示) 123 | var dbChannel = await BotManagement.GetChannelByTelegramIdAsync(BotId, ChannelTelegramId); 124 | var username = chatInfo?.Username ?? dbChannel?.Username; 125 | var memberCount = chatInfo?.MemberCount ?? dbChannel?.MemberCount ?? 0; 126 | 127 | await BotManagement.UpsertChannelAsync(new BotChannel 128 | { 129 | BotId = BotId, 130 | TelegramId = ChannelTelegramId, 131 | Title = title, 132 | Username = username, 133 | IsBroadcast = true, 134 | MemberCount = memberCount, 135 | About = normalizedAbout, 136 | AccessHash = dbChannel?.AccessHash, 137 | CreatedAt = dbChannel?.CreatedAt 138 | }); 139 | 140 | Snackbar.Add("保存成功", Severity.Success); 141 | MudDialog.Close(DialogResult.Ok(true)); 142 | } 143 | catch (Exception ex) 144 | { 145 | Snackbar.Add($"保存失败:{ex.Message}", Severity.Error); 146 | } 147 | finally 148 | { 149 | saving = false; 150 | } 151 | } 152 | 153 | private void Cancel() 154 | { 155 | MudDialog.Cancel(); 156 | } 157 | 158 | private void OnAvatarChanged(IReadOnlyList files) 159 | { 160 | avatarFile = files?.FirstOrDefault(); 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /src/TelegramPanel.Web/Services/BotAutoSyncBackgroundService.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.DependencyInjection; 2 | using Microsoft.Extensions.Hosting; 3 | using Microsoft.Extensions.Logging; 4 | using System.Text.Json; 5 | using TelegramPanel.Core.Services; 6 | using TelegramPanel.Core.Services.Telegram; 7 | 8 | namespace TelegramPanel.Web.Services; 9 | 10 | /// 11 | /// Bot 频道自动同步(轮询),用于在 Bot 被拉进新频道后自动出现在列表里。 12 | /// 13 | public class BotAutoSyncBackgroundService : BackgroundService 14 | { 15 | private readonly IServiceScopeFactory _scopeFactory; 16 | private readonly ILogger _logger; 17 | private readonly IConfiguration _configuration; 18 | private readonly BotUpdateHub _updateHub; 19 | 20 | public BotAutoSyncBackgroundService( 21 | IServiceScopeFactory scopeFactory, 22 | BotUpdateHub updateHub, 23 | IConfiguration configuration, 24 | ILogger logger) 25 | { 26 | _scopeFactory = scopeFactory; 27 | _updateHub = updateHub; 28 | _configuration = configuration; 29 | _logger = logger; 30 | } 31 | 32 | protected override async Task ExecuteAsync(CancellationToken stoppingToken) 33 | { 34 | // 默认关闭:避免对“后台自动同步”的误解;需要时可显式开启 35 | var enabled = _configuration.GetValue("Telegram:BotAutoSyncEnabled", false); 36 | if (!enabled) 37 | { 38 | _logger.LogInformation("Bot auto sync disabled (Telegram:BotAutoSyncEnabled=false)"); 39 | return; 40 | } 41 | 42 | // 这里的 interval 用于:刷新 bot 列表 + 批量 drain 已收集的 updates(避免每条 update 都写 DB) 43 | var seconds = _configuration.GetValue("Telegram:BotAutoSyncIntervalSeconds", 2); 44 | if (seconds < 2) seconds = 2; 45 | if (seconds > 60) seconds = 60; 46 | var interval = TimeSpan.FromSeconds(seconds); 47 | 48 | _logger.LogInformation("Bot auto sync started (via BotUpdateHub), interval {IntervalSeconds} seconds", seconds); 49 | 50 | // 延迟一点,避免与启动时 DB 迁移抢资源 51 | await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken); 52 | 53 | var subscriptions = new Dictionary(); 54 | 55 | while (!stoppingToken.IsCancellationRequested) 56 | { 57 | try 58 | { 59 | await SyncOnceAsync(subscriptions, stoppingToken); 60 | } 61 | catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) 62 | { 63 | // ignore 64 | } 65 | catch (Exception ex) 66 | { 67 | _logger.LogWarning(ex, "Bot auto sync loop failed"); 68 | } 69 | 70 | await Task.Delay(interval, stoppingToken); 71 | } 72 | 73 | foreach (var sub in subscriptions.Values) 74 | { 75 | try { await sub.DisposeAsync(); } 76 | catch { /* ignore */ } 77 | } 78 | } 79 | 80 | private async Task SyncOnceAsync( 81 | Dictionary subscriptions, 82 | CancellationToken cancellationToken) 83 | { 84 | using var scope = _scopeFactory.CreateScope(); 85 | var botManagement = scope.ServiceProvider.GetRequiredService(); 86 | var botTelegram = scope.ServiceProvider.GetRequiredService(); 87 | 88 | var bots = (await botManagement.GetAllBotsAsync()).Where(b => b.IsActive).ToList(); 89 | if (bots.Count == 0) 90 | return; 91 | 92 | // 1) 确保订阅已建立(每个 botId 一个订阅即可) 93 | var aliveBotIds = bots.Select(b => b.Id).ToHashSet(); 94 | foreach (var bot in bots) 95 | { 96 | cancellationToken.ThrowIfCancellationRequested(); 97 | 98 | if (subscriptions.ContainsKey(bot.Id)) 99 | continue; 100 | 101 | try 102 | { 103 | subscriptions[bot.Id] = await _updateHub.SubscribeAsync(bot.Id, cancellationToken); 104 | } 105 | catch (Exception ex) 106 | { 107 | _logger.LogWarning(ex, "Bot auto sync subscribe failed for bot {BotId}", bot.Id); 108 | } 109 | } 110 | 111 | // 2) 清理已停用/已删除 bot 的订阅 112 | foreach (var staleId in subscriptions.Keys.Where(id => !aliveBotIds.Contains(id)).ToList()) 113 | { 114 | if (subscriptions.Remove(staleId, out var sub)) 115 | { 116 | try { await sub.DisposeAsync(); } 117 | catch { /* ignore */ } 118 | } 119 | } 120 | 121 | // 3) 批量 drain:只取 my_chat_member,按 botId 分批应用(去重在 BotTelegramService 内部做) 122 | foreach (var bot in bots) 123 | { 124 | cancellationToken.ThrowIfCancellationRequested(); 125 | 126 | if (!subscriptions.TryGetValue(bot.Id, out var sub)) 127 | continue; 128 | 129 | var batch = new List(); 130 | while (sub.Reader.TryRead(out var update)) 131 | { 132 | if (update.ValueKind == System.Text.Json.JsonValueKind.Object 133 | && update.TryGetProperty("my_chat_member", out _)) 134 | { 135 | batch.Add(update); 136 | if (batch.Count >= 200) 137 | break; 138 | } 139 | } 140 | 141 | if (batch.Count == 0) 142 | continue; 143 | 144 | try 145 | { 146 | var count = await botTelegram.ApplyMyChatMemberUpdatesAsync(bot.Id, batch, cancellationToken); 147 | if (count > 0) 148 | _logger.LogInformation("Bot auto sync: bot {BotId} applied {Count} updates", bot.Id, count); 149 | else 150 | _logger.LogDebug("Bot auto sync: bot {BotId} applied 0 updates", bot.Id); 151 | } 152 | catch (Exception ex) 153 | { 154 | _logger.LogWarning(ex, "Bot auto sync apply failed for bot {BotId}", bot.Id); 155 | } 156 | } 157 | } 158 | } 159 | --------------------------------------------------------------------------------