From e601f0fa5612efd778e1dfc939016cc3d49347e1 Mon Sep 17 00:00:00 2001 From: meoacgx Date: Fri, 19 Dec 2025 17:50:25 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=9C=BA=E5=99=A8=E4=BA=BA?= =?UTF-8?q?=E7=AE=A1=E7=90=86=EF=BC=9ABot=20=E5=88=97=E8=A1=A8/=E9=A2=91?= =?UTF-8?q?=E9=81=93=E5=90=8C=E6=AD=A5/=E7=8B=AC=E7=AB=8B=E5=88=86?= =?UTF-8?q?=E7=B1=BB/=E5=A4=8D=E5=88=B6=E4=B8=8E=E5=AF=BC=E5=87=BA?= =?UTF-8?q?=E9=82=80=E8=AF=B7=E9=93=BE=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ServiceCollectionExtensions.cs | 2 + .../Services/BotManagementService.cs | 163 ++++++++++ .../Services/Telegram/BotTelegramService.cs | 269 +++++++++++++++ src/TelegramPanel.Data/AppDbContext.cs | 53 +++ src/TelegramPanel.Data/Entities/Bot.cs | 19 ++ src/TelegramPanel.Data/Entities/BotChannel.cs | 25 ++ .../Entities/BotChannelCategory.cs | 17 + .../Migrations/20251219000001_AddBots.cs | 131 ++++++++ .../BotChannelCategoryRepository.cs | 29 ++ .../Repositories/BotChannelRepository.cs | 33 ++ .../Repositories/BotRepository.cs | 31 ++ .../IBotChannelCategoryRepository.cs | 10 + .../Repositories/IBotChannelRepository.cs | 10 + .../Repositories/IBotRepository.cs | 10 + .../ServiceCollectionExtensions.cs | 3 + src/TelegramPanel.Web/Components/App.razor | 1 + .../Components/Layout/NavMenu.razor | 4 + .../Components/Pages/BotChannels.razor | 307 ++++++++++++++++++ .../Components/Pages/Bots.razor | 169 ++++++++++ src/TelegramPanel.Web/Program.cs | 52 +++ src/TelegramPanel.Web/wwwroot/app.js | 23 ++ 21 files changed, 1361 insertions(+) create mode 100644 src/TelegramPanel.Core/Services/BotManagementService.cs create mode 100644 src/TelegramPanel.Core/Services/Telegram/BotTelegramService.cs create mode 100644 src/TelegramPanel.Data/Entities/Bot.cs create mode 100644 src/TelegramPanel.Data/Entities/BotChannel.cs create mode 100644 src/TelegramPanel.Data/Entities/BotChannelCategory.cs create mode 100644 src/TelegramPanel.Data/Migrations/20251219000001_AddBots.cs create mode 100644 src/TelegramPanel.Data/Repositories/BotChannelCategoryRepository.cs create mode 100644 src/TelegramPanel.Data/Repositories/BotChannelRepository.cs create mode 100644 src/TelegramPanel.Data/Repositories/BotRepository.cs create mode 100644 src/TelegramPanel.Data/Repositories/IBotChannelCategoryRepository.cs create mode 100644 src/TelegramPanel.Data/Repositories/IBotChannelRepository.cs create mode 100644 src/TelegramPanel.Data/Repositories/IBotRepository.cs create mode 100644 src/TelegramPanel.Web/Components/Pages/BotChannels.razor create mode 100644 src/TelegramPanel.Web/Components/Pages/Bots.razor create mode 100644 src/TelegramPanel.Web/wwwroot/app.js diff --git a/src/TelegramPanel.Core/ServiceCollectionExtensions.cs b/src/TelegramPanel.Core/ServiceCollectionExtensions.cs index 3428514..1597170 100644 --- a/src/TelegramPanel.Core/ServiceCollectionExtensions.cs +++ b/src/TelegramPanel.Core/ServiceCollectionExtensions.cs @@ -21,6 +21,7 @@ public static class ServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); // 注册账号导入协调服务 services.AddScoped(); @@ -32,6 +33,7 @@ public static class ServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); return services; } diff --git a/src/TelegramPanel.Core/Services/BotManagementService.cs b/src/TelegramPanel.Core/Services/BotManagementService.cs new file mode 100644 index 0000000..12c2346 --- /dev/null +++ b/src/TelegramPanel.Core/Services/BotManagementService.cs @@ -0,0 +1,163 @@ +using Microsoft.Extensions.Logging; +using TelegramPanel.Data.Entities; +using TelegramPanel.Data.Repositories; + +namespace TelegramPanel.Core.Services; + +public class BotManagementService +{ + private readonly IBotRepository _botRepository; + private readonly IBotChannelRepository _botChannelRepository; + private readonly IBotChannelCategoryRepository _categoryRepository; + private readonly ILogger _logger; + + public BotManagementService( + IBotRepository botRepository, + IBotChannelRepository botChannelRepository, + IBotChannelCategoryRepository categoryRepository, + ILogger logger) + { + _botRepository = botRepository; + _botChannelRepository = botChannelRepository; + _categoryRepository = categoryRepository; + _logger = logger; + } + + public async Task> GetAllBotsAsync() + { + return await _botRepository.GetAllWithStatsAsync(); + } + + public async Task GetBotAsync(int id) + { + return await _botRepository.GetByIdAsync(id); + } + + public async Task CreateBotAsync(string name, string token, string? username = null) + { + name = (name ?? string.Empty).Trim(); + token = (token ?? string.Empty).Trim(); + username = string.IsNullOrWhiteSpace(username) ? null : username.Trim().TrimStart('@'); + + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("机器人名称不能为空", nameof(name)); + if (string.IsNullOrWhiteSpace(token)) + throw new ArgumentException("机器人 Token 不能为空", nameof(token)); + + var existing = await _botRepository.GetByNameAsync(name); + if (existing != null) + throw new InvalidOperationException("机器人名称已存在"); + + var bot = new Bot + { + Name = name, + Token = token, + Username = username, + IsActive = true, + CreatedAt = DateTime.UtcNow + }; + + return await _botRepository.AddAsync(bot); + } + + public async Task UpdateBotAsync(Bot bot) + { + bot.Name = (bot.Name ?? string.Empty).Trim(); + bot.Token = (bot.Token ?? string.Empty).Trim(); + bot.Username = string.IsNullOrWhiteSpace(bot.Username) ? null : bot.Username.Trim().TrimStart('@'); + + await _botRepository.UpdateAsync(bot); + } + + public async Task DeleteBotAsync(int id) + { + var bot = await _botRepository.GetByIdAsync(id); + if (bot == null) + return; + + await _botRepository.DeleteAsync(bot); + } + + public async Task> GetCategoriesAsync(int botId) + { + return await _categoryRepository.GetForBotAsync(botId); + } + + public async Task CreateCategoryAsync(int botId, string name, string? description = null) + { + name = (name ?? string.Empty).Trim(); + description = string.IsNullOrWhiteSpace(description) ? null : description.Trim(); + + if (botId <= 0) + throw new ArgumentException("botId 无效", nameof(botId)); + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("分类名称不能为空", nameof(name)); + + var existing = await _categoryRepository.GetByNameAsync(botId, name); + if (existing != null) + throw new InvalidOperationException("分类名称已存在"); + + var cat = new BotChannelCategory + { + BotId = botId, + Name = name, + Description = description, + CreatedAt = DateTime.UtcNow + }; + + return await _categoryRepository.AddAsync(cat); + } + + public async Task DeleteCategoryAsync(int categoryId) + { + var cat = await _categoryRepository.GetByIdAsync(categoryId); + if (cat == null) + return; + + await _categoryRepository.DeleteAsync(cat); + } + + public async Task> GetChannelsAsync(int botId, int? categoryId = null) + { + return await _botChannelRepository.GetForBotAsync(botId, categoryId); + } + + public async Task UpsertChannelAsync(BotChannel channel) + { + if (channel.BotId <= 0) + throw new ArgumentException("BotId 无效", nameof(channel)); + if (channel.TelegramId <= 0) + throw new ArgumentException("TelegramId 无效", nameof(channel)); + + var existing = await _botChannelRepository.GetByTelegramIdAsync(channel.BotId, channel.TelegramId); + if (existing != null) + { + existing.Title = channel.Title; + existing.Username = channel.Username; + existing.IsBroadcast = channel.IsBroadcast; + existing.MemberCount = channel.MemberCount; + existing.About = channel.About; + existing.AccessHash = channel.AccessHash; + if (channel.CreatedAt.HasValue) + existing.CreatedAt = channel.CreatedAt; + existing.SyncedAt = DateTime.UtcNow; + + await _botChannelRepository.UpdateAsync(existing); + return existing; + } + + channel.SyncedAt = DateTime.UtcNow; + return await _botChannelRepository.AddAsync(channel); + } + + public async Task SetChannelCategoryAsync(int botChannelId, int? categoryId) + { + var ch = await _botChannelRepository.GetByIdAsync(botChannelId); + if (ch == null) + return; + + ch.CategoryId = categoryId; + await _botChannelRepository.UpdateAsync(ch); + } +} + diff --git a/src/TelegramPanel.Core/Services/Telegram/BotTelegramService.cs b/src/TelegramPanel.Core/Services/Telegram/BotTelegramService.cs new file mode 100644 index 0000000..9d09bd4 --- /dev/null +++ b/src/TelegramPanel.Core/Services/Telegram/BotTelegramService.cs @@ -0,0 +1,269 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using TelegramPanel.Core.Services; +using TelegramPanel.Data.Entities; +using TL; +using WTelegram; + +namespace TelegramPanel.Core.Services.Telegram; + +public class BotTelegramService +{ + private readonly BotManagementService _botManagement; + private readonly IConfiguration _configuration; + private readonly ILogger _logger; + + public BotTelegramService( + BotManagementService botManagement, + IConfiguration configuration, + ILogger logger) + { + _botManagement = botManagement; + _configuration = configuration; + _logger = logger; + } + + public async Task SyncBotChannelsAsync(int botId, CancellationToken cancellationToken) + { + var bot = await _botManagement.GetBotAsync(botId) + ?? throw new InvalidOperationException($"机器人不存在:{botId}"); + + if (!bot.IsActive) + throw new InvalidOperationException("该机器人已停用"); + + if (!TryGetTelegramApi(out var apiId, out var apiHash)) + throw new InvalidOperationException("请先在【系统设置】中配置全局 Telegram API(ApiId/ApiHash)"); + + await using var client = CreateBotClient(bot, apiId, apiHash); + await EnsureBotLoginAsync(client, bot.Token); + + var dialogs = await client.Messages_GetAllDialogs(); + var synced = 0; + + foreach (var (_, chat) in dialogs.chats) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (chat is not TL.Channel channel || !channel.IsActive) + continue; + + var memberCount = TryReadInt(channel, 0, "participants_count", "ParticipantsCount", "participantsCount"); + string? about = null; + try + { + var full = await client.Channels_GetFullChannel(channel); + memberCount = full.full_chat.ParticipantsCount; + about = (full.full_chat as ChannelFull)?.about; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to get full channel info for bot {BotId} channel {ChannelId}", botId, channel.id); + } + + await _botManagement.UpsertChannelAsync(new BotChannel + { + BotId = bot.Id, + TelegramId = channel.id, + AccessHash = channel.access_hash, + Title = channel.title, + Username = channel.MainUsername, + IsBroadcast = channel.IsChannel, + MemberCount = memberCount, + About = about, + CreatedAt = channel.date + }); + + synced++; + } + + bot.LastSyncAt = DateTime.UtcNow; + await _botManagement.UpdateBotAsync(bot); + + return synced; + } + + public async Task GetPublicLinkAsync(int botId, long channelTelegramId, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + var bot = await _botManagement.GetBotAsync(botId) + ?? throw new InvalidOperationException($"机器人不存在:{botId}"); + + if (!TryGetTelegramApi(out var apiId, out var apiHash)) + throw new InvalidOperationException("请先在【系统设置】中配置全局 Telegram API(ApiId/ApiHash)"); + + await using var client = CreateBotClient(bot, apiId, apiHash); + await EnsureBotLoginAsync(client, bot.Token); + + var channel = await FindChannelAsync(client, channelTelegramId); + if (channel == null) + throw new InvalidOperationException($"频道不存在或机器人不可见:{channelTelegramId}"); + + if (!string.IsNullOrWhiteSpace(channel.MainUsername)) + return $"https://t.me/{channel.MainUsername}"; + + throw new InvalidOperationException("该频道没有公开用户名"); + } + + public async Task ExportInviteLinkAsync(int botId, long channelTelegramId, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + var bot = await _botManagement.GetBotAsync(botId) + ?? throw new InvalidOperationException($"机器人不存在:{botId}"); + + if (!TryGetTelegramApi(out var apiId, out var apiHash)) + throw new InvalidOperationException("请先在【系统设置】中配置全局 Telegram API(ApiId/ApiHash)"); + + await using var client = CreateBotClient(bot, apiId, apiHash); + await EnsureBotLoginAsync(client, bot.Token); + + var channel = await FindChannelAsync(client, channelTelegramId); + if (channel == null) + throw new InvalidOperationException($"频道不存在或机器人不可见:{channelTelegramId}"); + + if (!string.IsNullOrWhiteSpace(channel.MainUsername)) + return $"https://t.me/{channel.MainUsername}"; + + var invite = await client.Messages_ExportChatInvite(channel); + var link = invite switch + { + ChatInviteExported e => e.link, + _ => null + }; + + if (string.IsNullOrWhiteSpace(link)) + throw new InvalidOperationException("无法获取邀请链接(可能无权限)"); + + return link; + } + + public async Task> ExportInviteLinksAsync(int botId, IReadOnlyList channelTelegramIds, CancellationToken cancellationToken) + { + if (channelTelegramIds.Count == 0) + return new Dictionary(); + + var bot = await _botManagement.GetBotAsync(botId) + ?? throw new InvalidOperationException($"机器人不存在:{botId}"); + + if (!TryGetTelegramApi(out var apiId, out var apiHash)) + throw new InvalidOperationException("请先在【系统设置】中配置全局 Telegram API(ApiId/ApiHash)"); + + await using var client = CreateBotClient(bot, apiId, apiHash); + await EnsureBotLoginAsync(client, bot.Token); + + var dialogs = await client.Messages_GetAllDialogs(); + var channels = dialogs.chats.Values.OfType().Where(c => c.IsActive).ToDictionary(c => c.id, c => c); + + var map = new Dictionary(); + foreach (var id in channelTelegramIds.Distinct()) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (!channels.TryGetValue(id, out var ch)) + continue; + + if (!string.IsNullOrWhiteSpace(ch.MainUsername)) + { + map[id] = $"https://t.me/{ch.MainUsername}"; + continue; + } + + var invite = await client.Messages_ExportChatInvite(ch); + var link = invite switch + { + ChatInviteExported e => e.link, + _ => null + }; + map[id] = string.IsNullOrWhiteSpace(link) ? "(无法获取邀请链接/无权限)" : link; + } + + return map; + } + + private bool TryGetTelegramApi(out int apiId, out string apiHash) + { + apiHash = (_configuration["Telegram:ApiHash"] ?? string.Empty).Trim(); + return int.TryParse(_configuration["Telegram:ApiId"], out apiId) + && apiId > 0 + && !string.IsNullOrWhiteSpace(apiHash); + } + + private Client CreateBotClient(Bot bot, int apiId, string apiHash) + { + var sessionsPath = _configuration["Telegram:BotSessionsPath"]; + if (string.IsNullOrWhiteSpace(sessionsPath)) + { + var root = _configuration["Telegram:SessionsPath"] ?? "sessions"; + sessionsPath = Path.Combine(root, "bots"); + } + + Directory.CreateDirectory(sessionsPath); + var sessionPath = Path.Combine(sessionsPath, $"bot_{bot.Id}.session"); + + string Config(string what) + { + return what switch + { + "api_id" => apiId.ToString(), + "api_hash" => apiHash, + "session_pathname" => sessionPath, + "session_key" => apiHash, + "bot_token" => bot.Token, + _ => null! + }; + } + + return new Client(Config); + } + + private static async Task EnsureBotLoginAsync(Client client, string token) + { + token = (token ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(token)) + throw new InvalidOperationException("Bot Token 为空"); + + await client.ConnectAsync(); + + // WTelegram: Bot 登录 + await client.LoginBotIfNeeded(token); + + if (client.User == null) + throw new InvalidOperationException("机器人登录失败"); + } + + private static async Task FindChannelAsync(Client client, long channelTelegramId) + { + var dialogs = await client.Messages_GetAllDialogs(); + return dialogs.chats.Values.OfType().FirstOrDefault(c => c.id == channelTelegramId); + } + + private static int TryReadInt(object obj, int fallback, params string[] names) + { + var type = obj.GetType(); + foreach (var name in names) + { + var prop = type.GetProperty(name); + if (prop != null && prop.CanRead) + { + var v = prop.GetValue(obj); + if (v is int i) return i; + if (v is long l) return unchecked((int)l); + if (v is short s) return s; + if (v is byte by) return by; + } + + var field = type.GetField(name); + if (field != null) + { + var v = field.GetValue(obj); + if (v is int i) return i; + if (v is long l) return unchecked((int)l); + if (v is short s) return s; + if (v is byte by) return by; + } + } + + return fallback; + } +} diff --git a/src/TelegramPanel.Data/AppDbContext.cs b/src/TelegramPanel.Data/AppDbContext.cs index e5021ee..9cc7bde 100644 --- a/src/TelegramPanel.Data/AppDbContext.cs +++ b/src/TelegramPanel.Data/AppDbContext.cs @@ -20,6 +20,9 @@ public class AppDbContext : DbContext public DbSet ChannelGroups => Set(); public DbSet Groups => Set(); public DbSet BatchTasks => Set(); + public DbSet Bots => Set(); + public DbSet BotChannels => Set(); + public DbSet BotChannelCategories => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -137,5 +140,55 @@ public class AppDbContext : DbContext entity.HasIndex(e => e.Status); entity.HasIndex(e => e.CreatedAt); }); + + // Bot配置 + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.Property(e => e.Name).IsRequired().HasMaxLength(100); + entity.Property(e => e.Token).IsRequired().HasMaxLength(200); + entity.Property(e => e.Username).HasMaxLength(100); + + entity.HasIndex(e => e.Name).IsUnique(); + entity.HasIndex(e => e.Username); + }); + + // BotChannelCategory配置 + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.Property(e => e.Name).IsRequired().HasMaxLength(100); + entity.Property(e => e.Description).HasMaxLength(500); + + entity.HasIndex(e => new { e.BotId, e.Name }).IsUnique(); + + entity.HasOne(e => e.Bot) + .WithMany(b => b.Categories) + .HasForeignKey(e => e.BotId) + .OnDelete(DeleteBehavior.Cascade); + }); + + // BotChannel配置 + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.Property(e => e.Title).IsRequired().HasMaxLength(200); + entity.Property(e => e.Username).HasMaxLength(100); + entity.Property(e => e.About).HasMaxLength(1000); + + entity.HasIndex(e => new { e.BotId, e.TelegramId }).IsUnique(); + entity.HasIndex(e => e.Username); + entity.HasIndex(e => e.CategoryId); + + entity.HasOne(e => e.Bot) + .WithMany(b => b.Channels) + .HasForeignKey(e => e.BotId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasOne(e => e.Category) + .WithMany(c => c.Channels) + .HasForeignKey(e => e.CategoryId) + .OnDelete(DeleteBehavior.SetNull); + }); } } diff --git a/src/TelegramPanel.Data/Entities/Bot.cs b/src/TelegramPanel.Data/Entities/Bot.cs new file mode 100644 index 0000000..13bf1fb --- /dev/null +++ b/src/TelegramPanel.Data/Entities/Bot.cs @@ -0,0 +1,19 @@ +namespace TelegramPanel.Data.Entities; + +/// +/// Telegram 机器人(Bot)实体 +/// +public class Bot +{ + public int Id { get; set; } + public string Name { get; set; } = null!; + public string Token { get; set; } = null!; + public string? Username { get; set; } + public bool IsActive { get; set; } = true; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime? LastSyncAt { get; set; } + + public ICollection Categories { get; set; } = new List(); + public ICollection Channels { get; set; } = new List(); +} + diff --git a/src/TelegramPanel.Data/Entities/BotChannel.cs b/src/TelegramPanel.Data/Entities/BotChannel.cs new file mode 100644 index 0000000..5450978 --- /dev/null +++ b/src/TelegramPanel.Data/Entities/BotChannel.cs @@ -0,0 +1,25 @@ +namespace TelegramPanel.Data.Entities; + +/// +/// Bot 加入的频道(用于管理“非创建者频道”) +/// +public class BotChannel +{ + public int Id { get; set; } + public int BotId { get; set; } + public long TelegramId { get; set; } + public long? AccessHash { get; set; } + public string Title { get; set; } = null!; + public string? Username { get; set; } + public bool IsBroadcast { get; set; } + public int MemberCount { get; set; } + public string? About { get; set; } + public DateTime? CreatedAt { get; set; } + public DateTime SyncedAt { get; set; } = DateTime.UtcNow; + + public int? CategoryId { get; set; } + + public Bot? Bot { get; set; } + public BotChannelCategory? Category { get; set; } +} + diff --git a/src/TelegramPanel.Data/Entities/BotChannelCategory.cs b/src/TelegramPanel.Data/Entities/BotChannelCategory.cs new file mode 100644 index 0000000..6de173a --- /dev/null +++ b/src/TelegramPanel.Data/Entities/BotChannelCategory.cs @@ -0,0 +1,17 @@ +namespace TelegramPanel.Data.Entities; + +/// +/// Bot 频道分类(独立于账号频道分类/分组) +/// +public class BotChannelCategory +{ + public int Id { get; set; } + public int BotId { get; set; } + public string Name { get; set; } = null!; + public string? Description { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + public Bot? Bot { get; set; } + public ICollection Channels { get; set; } = new List(); +} + diff --git a/src/TelegramPanel.Data/Migrations/20251219000001_AddBots.cs b/src/TelegramPanel.Data/Migrations/20251219000001_AddBots.cs new file mode 100644 index 0000000..20e86d4 --- /dev/null +++ b/src/TelegramPanel.Data/Migrations/20251219000001_AddBots.cs @@ -0,0 +1,131 @@ +using System; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TelegramPanel.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20251219000001_AddBots")] + public partial class AddBots : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Bots", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Name = table.Column(type: "TEXT", maxLength: 100, nullable: false), + Token = table.Column(type: "TEXT", maxLength: 200, nullable: false), + Username = table.Column(type: "TEXT", maxLength: 100, nullable: true), + IsActive = table.Column(type: "INTEGER", nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false), + LastSyncAt = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Bots", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_Bots_Name", + table: "Bots", + column: "Name", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Bots_Username", + table: "Bots", + column: "Username"); + + migrationBuilder.CreateTable( + name: "BotChannelCategories", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + BotId = table.Column(type: "INTEGER", nullable: false), + Name = table.Column(type: "TEXT", maxLength: 100, nullable: false), + Description = table.Column(type: "TEXT", maxLength: 500, nullable: true), + CreatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_BotChannelCategories", x => x.Id); + table.ForeignKey( + name: "FK_BotChannelCategories_Bots_BotId", + column: x => x.BotId, + principalTable: "Bots", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_BotChannelCategories_BotId_Name", + table: "BotChannelCategories", + columns: new[] { "BotId", "Name" }, + unique: true); + + migrationBuilder.CreateTable( + name: "BotChannels", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + BotId = table.Column(type: "INTEGER", nullable: false), + TelegramId = table.Column(type: "INTEGER", nullable: false), + AccessHash = table.Column(type: "INTEGER", nullable: true), + Title = table.Column(type: "TEXT", maxLength: 200, nullable: false), + Username = table.Column(type: "TEXT", maxLength: 100, nullable: true), + IsBroadcast = table.Column(type: "INTEGER", nullable: false), + MemberCount = table.Column(type: "INTEGER", nullable: false), + About = table.Column(type: "TEXT", maxLength: 1000, nullable: true), + CreatedAt = table.Column(type: "TEXT", nullable: true), + SyncedAt = table.Column(type: "TEXT", nullable: false), + CategoryId = table.Column(type: "INTEGER", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_BotChannels", x => x.Id); + table.ForeignKey( + name: "FK_BotChannels_Bots_BotId", + column: x => x.BotId, + principalTable: "Bots", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_BotChannels_BotChannelCategories_CategoryId", + column: x => x.CategoryId, + principalTable: "BotChannelCategories", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + }); + + migrationBuilder.CreateIndex( + name: "IX_BotChannels_BotId_TelegramId", + table: "BotChannels", + columns: new[] { "BotId", "TelegramId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_BotChannels_CategoryId", + table: "BotChannels", + column: "CategoryId"); + + migrationBuilder.CreateIndex( + name: "IX_BotChannels_Username", + table: "BotChannels", + column: "Username"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable(name: "BotChannels"); + migrationBuilder.DropTable(name: "BotChannelCategories"); + migrationBuilder.DropTable(name: "Bots"); + } + } +} diff --git a/src/TelegramPanel.Data/Repositories/BotChannelCategoryRepository.cs b/src/TelegramPanel.Data/Repositories/BotChannelCategoryRepository.cs new file mode 100644 index 0000000..92dde68 --- /dev/null +++ b/src/TelegramPanel.Data/Repositories/BotChannelCategoryRepository.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore; +using TelegramPanel.Data.Entities; + +namespace TelegramPanel.Data.Repositories; + +public class BotChannelCategoryRepository : Repository, IBotChannelCategoryRepository +{ + public BotChannelCategoryRepository(AppDbContext context) : base(context) + { + } + + public async Task> GetForBotAsync(int botId) + { + return await _dbSet + .Where(x => x.BotId == botId) + .OrderBy(x => x.Name) + .ToListAsync(); + } + + public async Task GetByNameAsync(int botId, string name) + { + name = (name ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(name)) + return null; + + return await _dbSet.FirstOrDefaultAsync(x => x.BotId == botId && x.Name == name); + } +} + diff --git a/src/TelegramPanel.Data/Repositories/BotChannelRepository.cs b/src/TelegramPanel.Data/Repositories/BotChannelRepository.cs new file mode 100644 index 0000000..23459ae --- /dev/null +++ b/src/TelegramPanel.Data/Repositories/BotChannelRepository.cs @@ -0,0 +1,33 @@ +using Microsoft.EntityFrameworkCore; +using TelegramPanel.Data.Entities; + +namespace TelegramPanel.Data.Repositories; + +public class BotChannelRepository : Repository, IBotChannelRepository +{ + public BotChannelRepository(AppDbContext context) : base(context) + { + } + + public async Task GetByTelegramIdAsync(int botId, long telegramId) + { + return await _dbSet + .Include(x => x.Category) + .FirstOrDefaultAsync(x => x.BotId == botId && x.TelegramId == telegramId); + } + + public async Task> GetForBotAsync(int botId, int? categoryId = null) + { + var query = _dbSet + .Include(x => x.Category) + .Where(x => x.BotId == botId); + + if (categoryId.HasValue) + query = query.Where(x => x.CategoryId == categoryId.Value); + + return await query + .OrderByDescending(x => x.SyncedAt) + .ToListAsync(); + } +} + diff --git a/src/TelegramPanel.Data/Repositories/BotRepository.cs b/src/TelegramPanel.Data/Repositories/BotRepository.cs new file mode 100644 index 0000000..ed3421a --- /dev/null +++ b/src/TelegramPanel.Data/Repositories/BotRepository.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore; +using TelegramPanel.Data.Entities; + +namespace TelegramPanel.Data.Repositories; + +public class BotRepository : Repository, IBotRepository +{ + public BotRepository(AppDbContext context) : base(context) + { + } + + public async Task GetByNameAsync(string name) + { + name = (name ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(name)) + return null; + + return await _dbSet.FirstOrDefaultAsync(x => x.Name == name); + } + + public async Task> GetAllWithStatsAsync() + { + // 只用于列表显示统计信息,避免一次性加载全部频道详情 + return await _dbSet + .Include(x => x.Channels) + .Include(x => x.Categories) + .OrderByDescending(x => x.CreatedAt) + .ToListAsync(); + } +} + diff --git a/src/TelegramPanel.Data/Repositories/IBotChannelCategoryRepository.cs b/src/TelegramPanel.Data/Repositories/IBotChannelCategoryRepository.cs new file mode 100644 index 0000000..eb55f0f --- /dev/null +++ b/src/TelegramPanel.Data/Repositories/IBotChannelCategoryRepository.cs @@ -0,0 +1,10 @@ +using TelegramPanel.Data.Entities; + +namespace TelegramPanel.Data.Repositories; + +public interface IBotChannelCategoryRepository : IRepository +{ + Task> GetForBotAsync(int botId); + Task GetByNameAsync(int botId, string name); +} + diff --git a/src/TelegramPanel.Data/Repositories/IBotChannelRepository.cs b/src/TelegramPanel.Data/Repositories/IBotChannelRepository.cs new file mode 100644 index 0000000..37c38bd --- /dev/null +++ b/src/TelegramPanel.Data/Repositories/IBotChannelRepository.cs @@ -0,0 +1,10 @@ +using TelegramPanel.Data.Entities; + +namespace TelegramPanel.Data.Repositories; + +public interface IBotChannelRepository : IRepository +{ + Task GetByTelegramIdAsync(int botId, long telegramId); + Task> GetForBotAsync(int botId, int? categoryId = null); +} + diff --git a/src/TelegramPanel.Data/Repositories/IBotRepository.cs b/src/TelegramPanel.Data/Repositories/IBotRepository.cs new file mode 100644 index 0000000..d457d0f --- /dev/null +++ b/src/TelegramPanel.Data/Repositories/IBotRepository.cs @@ -0,0 +1,10 @@ +using TelegramPanel.Data.Entities; + +namespace TelegramPanel.Data.Repositories; + +public interface IBotRepository : IRepository +{ + Task GetByNameAsync(string name); + Task> GetAllWithStatsAsync(); +} + diff --git a/src/TelegramPanel.Data/ServiceCollectionExtensions.cs b/src/TelegramPanel.Data/ServiceCollectionExtensions.cs index 6000099..bec61fc 100644 --- a/src/TelegramPanel.Data/ServiceCollectionExtensions.cs +++ b/src/TelegramPanel.Data/ServiceCollectionExtensions.cs @@ -23,6 +23,9 @@ public static class ServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(typeof(IRepository<>), typeof(Repository<>)); return services; diff --git a/src/TelegramPanel.Web/Components/App.razor b/src/TelegramPanel.Web/Components/App.razor index 76570e2..cf405a7 100644 --- a/src/TelegramPanel.Web/Components/App.razor +++ b/src/TelegramPanel.Web/Components/App.razor @@ -14,6 +14,7 @@ + diff --git a/src/TelegramPanel.Web/Components/Layout/NavMenu.razor b/src/TelegramPanel.Web/Components/Layout/NavMenu.razor index 7550f6c..825c914 100644 --- a/src/TelegramPanel.Web/Components/Layout/NavMenu.razor +++ b/src/TelegramPanel.Web/Components/Layout/NavMenu.razor @@ -25,6 +25,10 @@ 批量设管理 + + 机器人列表 + + 任务中心 diff --git a/src/TelegramPanel.Web/Components/Pages/BotChannels.razor b/src/TelegramPanel.Web/Components/Pages/BotChannels.razor new file mode 100644 index 0000000..50904c5 --- /dev/null +++ b/src/TelegramPanel.Web/Components/Pages/BotChannels.razor @@ -0,0 +1,307 @@ +@page "/bots/{BotId:int}/channels" +@inject BotManagementService BotManagement +@inject TelegramPanel.Core.Services.Telegram.BotTelegramService BotTelegram +@inject ISnackbar Snackbar +@inject IDialogService DialogService +@inject NavigationManager Navigation +@inject IJSRuntime JS +@using TelegramPanel.Data.Entities + +Bot 频道 - Telegram Panel + +Bot 频道 +@if (bot != null) +{ + @bot.Name @(!string.IsNullOrWhiteSpace(bot.Username) ? $"(@{bot.Username})" : "") +} + + + + + + 频道 (@channels.Count) + + + + 全部分类 + 未分类 + @foreach (var c in categories) + { + @c.Name + } + + + + + + 同步 Bot 频道 + + + + 导出邀请(已选) + + + + 刷新 + + + + + 频道名称 + 用户名 + 分类 + 最后同步 + 操作 + + + + @context.Title + + + @if (!string.IsNullOrWhiteSpace(context.Username)) + { + @context.Username + } + else + { + - + } + + + + 未分类 + @foreach (var c in categories) + { + @c.Name + } + + + @context.SyncedAt.ToString("yyyy-MM-dd HH:mm") + + + 复制链接 + 复制邀请 + + + + + 暂无频道 + + + + + + + + 新增分类 + + + + + + + + + + 创建 + + + + + + +@code { + [Parameter] public int BotId { get; set; } + + private bool loading = true; + private Bot? bot; + private List categories = new(); + private List channels = new(); + private HashSet selected = new(); + + private int filterCategoryId = 0; + private string search = ""; + + private string newCategoryName = ""; + private string newCategoryDesc = ""; + + private IEnumerable filtered => + channels.Where(c => + { + if (filterCategoryId == -1 && c.CategoryId != null) + return false; + if (filterCategoryId > 0 && c.CategoryId != filterCategoryId) + return false; + + if (string.IsNullOrWhiteSpace(search)) + return true; + + return c.Title.Contains(search, StringComparison.OrdinalIgnoreCase) + || (c.Username?.Contains(search, StringComparison.OrdinalIgnoreCase) ?? false); + }); + + protected override async Task OnInitializedAsync() + { + await LoadAll(); + } + + private async Task LoadAll() + { + loading = true; + try + { + bot = await BotManagement.GetBotAsync(BotId); + if (bot == null) + { + Snackbar.Add("机器人不存在", Severity.Error); + Navigation.NavigateTo("/bots"); + return; + } + + categories = (await BotManagement.GetCategoriesAsync(BotId)).ToList(); + channels = (await BotManagement.GetChannelsAsync(BotId)).ToList(); + + // 清理已选 + var ids = channels.Select(x => x.Id).ToHashSet(); + selected = selected.Where(x => ids.Contains(x.Id)).ToHashSet(); + } + catch (Exception ex) + { + Snackbar.Add($"加载失败:{ex.Message}", Severity.Error); + } + finally + { + loading = false; + } + } + + private async Task Sync() + { + loading = true; + try + { + var count = await BotTelegram.SyncBotChannelsAsync(BotId, CancellationToken.None); + Snackbar.Add($"同步完成:{count} 个频道", Severity.Success); + await LoadAll(); + } + catch (Exception ex) + { + Snackbar.Add($"同步失败:{ex.Message}", Severity.Error); + } + finally + { + loading = false; + } + } + + private async Task CreateCategoryAsync() + { + if (string.IsNullOrWhiteSpace(newCategoryName)) + { + Snackbar.Add("请输入分类名称", Severity.Warning); + return; + } + + loading = true; + try + { + await BotManagement.CreateCategoryAsync(BotId, newCategoryName, newCategoryDesc); + newCategoryName = ""; + newCategoryDesc = ""; + await LoadAll(); + Snackbar.Add("分类已创建", Severity.Success); + } + catch (Exception ex) + { + Snackbar.Add($"创建失败:{ex.Message}", Severity.Error); + } + finally + { + loading = false; + } + } + + private async Task SetCategoryAsync(BotChannel channel, int? categoryId) + { + try + { + await BotManagement.SetChannelCategoryAsync(channel.Id, categoryId); + channel.CategoryId = categoryId; + Snackbar.Add("已保存分类", Severity.Success); + } + catch (Exception ex) + { + Snackbar.Add($"保存失败:{ex.Message}", Severity.Error); + } + } + + private async Task CopyPublicLinkAsync(BotChannel channel) + { + try + { + if (string.IsNullOrWhiteSpace(channel.Username)) + { + Snackbar.Add("该频道没有公开链接(无用户名)", Severity.Info); + return; + } + + var link = $"https://t.me/{channel.Username.Trim().TrimStart('@')}"; + await JS.InvokeVoidAsync("telegramPanel.copyText", link); + Snackbar.Add("已复制链接", Severity.Success); + } + catch (Exception ex) + { + Snackbar.Add($"复制失败:{ex.Message}", Severity.Error); + } + } + + private async Task CopyInviteAsync(BotChannel channel) + { + loading = true; + try + { + var link = await BotTelegram.ExportInviteLinkAsync(BotId, channel.TelegramId, CancellationToken.None); + await JS.InvokeVoidAsync("telegramPanel.copyText", link); + Snackbar.Add("已复制邀请链接", Severity.Success); + } + catch (Exception ex) + { + Snackbar.Add($"获取邀请失败:{ex.Message}", Severity.Error); + } + finally + { + loading = false; + } + } + + private async Task ExportInvites() + { + if (selected.Count == 0) + { + Snackbar.Add("请先选择频道", Severity.Info); + return; + } + + bool? result = await DialogService.ShowMessageBox( + "确认导出", + $"将为 {selected.Count} 个频道生成邀请链接并导出(需要机器人具备管理权限)。是否继续?", + yesText: "继续", cancelText: "取消"); + + if (result != true) + return; + + var ids = string.Join(",", selected.Select(x => x.TelegramId)); + var url = $"/downloads/bots/{BotId}/invites.txt?ids={Uri.EscapeDataString(ids)}"; + Navigation.NavigateTo(url, forceLoad: true); + await Task.CompletedTask; + } +} + diff --git a/src/TelegramPanel.Web/Components/Pages/Bots.razor b/src/TelegramPanel.Web/Components/Pages/Bots.razor new file mode 100644 index 0000000..beaf7ed --- /dev/null +++ b/src/TelegramPanel.Web/Components/Pages/Bots.razor @@ -0,0 +1,169 @@ +@page "/bots" +@inject BotManagementService BotManagement +@inject ISnackbar Snackbar +@inject IDialogService DialogService +@inject NavigationManager Navigation +@using TelegramPanel.Data.Entities + +机器人管理 - Telegram Panel + +机器人管理 + + + + + + 新增机器人 + + + + + 添加 + + + + + + + + + + + 机器人 (@bots.Count) + + + 刷新 + + + + 名称 + 用户名 + 频道数 + 分类数 + 最后同步 + 状态 + 操作 + + + @context.Name + @(!string.IsNullOrWhiteSpace(context.Username) ? $"@{context.Username}" : "-") + @context.Channels.Count + @context.Categories.Count + @((context.LastSyncAt.HasValue ? context.LastSyncAt.Value.ToString("yyyy-MM-dd HH:mm") : "-")) + + + @(context.IsActive ? "启用" : "停用") + + + + + + 频道列表 + + + + 删除 + + + + + + 暂无机器人 + + + + + + + +@code { + private bool loading = true; + private List bots = new(); + + private string newName = ""; + private string newToken = ""; + private string newUsername = ""; + + protected override async Task OnInitializedAsync() + { + await LoadBots(); + } + + private async Task LoadBots() + { + loading = true; + try + { + var list = await BotManagement.GetAllBotsAsync(); + bots = list.ToList(); + } + catch (Exception ex) + { + Snackbar.Add($"加载失败:{ex.Message}", Severity.Error); + } + finally + { + loading = false; + } + } + + private async Task CreateBotAsync() + { + if (loading) return; + if (string.IsNullOrWhiteSpace(newName) || string.IsNullOrWhiteSpace(newToken)) + { + Snackbar.Add("请填写名称和 Token", Severity.Warning); + return; + } + + loading = true; + try + { + await BotManagement.CreateBotAsync(newName, newToken, newUsername); + newName = ""; + newToken = ""; + newUsername = ""; + await LoadBots(); + Snackbar.Add("机器人已创建", Severity.Success); + } + catch (Exception ex) + { + Snackbar.Add($"创建失败:{ex.Message}", Severity.Error); + } + finally + { + loading = false; + } + } + + private async Task DeleteBotAsync(Bot bot) + { + bool? result = await DialogService.ShowMessageBox( + "确认删除", + $"确定要删除机器人 {bot.Name} 吗?该机器人加入的频道与分类也会一并删除。", + yesText: "删除", cancelText: "取消"); + + if (result != true) + return; + + loading = true; + try + { + await BotManagement.DeleteBotAsync(bot.Id); + await LoadBots(); + Snackbar.Add("删除成功", Severity.Success); + } + catch (Exception ex) + { + Snackbar.Add($"删除失败:{ex.Message}", Severity.Error); + } + finally + { + loading = false; + } + } +} diff --git a/src/TelegramPanel.Web/Program.cs b/src/TelegramPanel.Web/Program.cs index 4131b4f..e1d7768 100644 --- a/src/TelegramPanel.Web/Program.cs +++ b/src/TelegramPanel.Web/Program.cs @@ -3,6 +3,7 @@ using Microsoft.EntityFrameworkCore; using Serilog; using TelegramPanel.Core; using TelegramPanel.Core.Services; +using TelegramPanel.Core.Services.Telegram; using TelegramPanel.Data; using TelegramPanel.Web.Services; @@ -304,6 +305,57 @@ app.MapGet("/downloads/accounts.zip", async ( return Results.File(zipBytes, "application/zip", fileName); }).DisableAntiforgery(); +// 下载:导出 Bot 邀请链接(文本) +app.MapGet("/downloads/bots/{botId:int}/invites.txt", async ( + HttpContext http, + int botId, + BotManagementService botManagement, + BotTelegramService botTelegram, + CancellationToken cancellationToken) => +{ + var bot = await botManagement.GetBotAsync(botId) + ?? throw new InvalidOperationException($"机器人不存在:{botId}"); + + var idsRaw = http.Request.Query["ids"].ToString(); + IReadOnlyList telegramIds; + if (string.IsNullOrWhiteSpace(idsRaw)) + { + telegramIds = (await botManagement.GetChannelsAsync(botId)).Select(x => x.TelegramId).ToList(); + } + else + { + telegramIds = idsRaw + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(s => long.TryParse(s, out var x) ? x : 0) + .Where(x => x > 0) + .Distinct() + .ToList(); + } + + var links = await botTelegram.ExportInviteLinksAsync(botId, telegramIds, cancellationToken); + + var lines = new List + { + $"# Bot: {bot.Name}", + $"# ExportedAtUtc: {DateTime.UtcNow:O}", + "# Format: \\t", + "" + }; + + foreach (var id in telegramIds) + { + if (links.TryGetValue(id, out var link)) + lines.Add($"{id}\t{link}"); + else + lines.Add($"{id}\t(无法生成/不可见/无权限)"); + } + + var text = string.Join(Environment.NewLine, lines); + var bytes = System.Text.Encoding.UTF8.GetBytes(text); + var fileName = $"telegram-panel-bot-invites-{botId}-{DateTime.UtcNow:yyyyMMdd-HHmmss}.txt"; + return Results.File(bytes, "text/plain; charset=utf-8", fileName); +}).DisableAntiforgery(); + // TODO: Hangfire Dashboard // app.MapHangfireDashboard("/hangfire"); diff --git a/src/TelegramPanel.Web/wwwroot/app.js b/src/TelegramPanel.Web/wwwroot/app.js new file mode 100644 index 0000000..77609be --- /dev/null +++ b/src/TelegramPanel.Web/wwwroot/app.js @@ -0,0 +1,23 @@ +window.telegramPanel = window.telegramPanel || {}; + +window.telegramPanel.copyText = async (text) => { + if (text === null || text === undefined) return; + const value = String(text); + + if (navigator && navigator.clipboard && navigator.clipboard.writeText) { + await navigator.clipboard.writeText(value); + return; + } + + const textarea = document.createElement("textarea"); + textarea.value = value; + textarea.setAttribute("readonly", "true"); + textarea.style.position = "fixed"; + textarea.style.left = "-9999px"; + textarea.style.top = "-9999px"; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand("copy"); + document.body.removeChild(textarea); +}; +