新增机器人管理:Bot 列表/频道同步/独立分类/复制与导出邀请链接
This commit is contained in:
1 parent
04bbdaa4fb
commit
e601f0fa56
21 files changed
+1361
No files matched your search
@@ -21,6 +21,7 @@ public static class ServiceCollectionExtensions
|
||||
services.AddScoped<IGroupService, GroupService>();
|
||||
services.AddScoped<ISessionImporter, SessionImporter>();
|
||||
services.AddScoped<AccountTelegramToolsService>();
|
||||
services.AddScoped<BotTelegramService>();
|
||||
|
||||
// 注册账号导入协调服务
|
||||
services.AddScoped<AccountImportService>();
|
||||
@@ -32,6 +33,7 @@ public static class ServiceCollectionExtensions
|
||||
services.AddScoped<ChannelGroupManagementService>();
|
||||
services.AddScoped<GroupManagementService>();
|
||||
services.AddScoped<BatchTaskManagementService>();
|
||||
services.AddScoped<BotManagementService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -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<BotManagementService> _logger;
|
||||
|
||||
public BotManagementService(
|
||||
IBotRepository botRepository,
|
||||
IBotChannelRepository botChannelRepository,
|
||||
IBotChannelCategoryRepository categoryRepository,
|
||||
ILogger<BotManagementService> logger)
|
||||
{
|
||||
_botRepository = botRepository;
|
||||
_botChannelRepository = botChannelRepository;
|
||||
_categoryRepository = categoryRepository;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Bot>> GetAllBotsAsync()
|
||||
{
|
||||
return await _botRepository.GetAllWithStatsAsync();
|
||||
}
|
||||
|
||||
public async Task<Bot?> GetBotAsync(int id)
|
||||
{
|
||||
return await _botRepository.GetByIdAsync(id);
|
||||
}
|
||||
|
||||
public async Task<Bot> 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<IEnumerable<BotChannelCategory>> GetCategoriesAsync(int botId)
|
||||
{
|
||||
return await _categoryRepository.GetForBotAsync(botId);
|
||||
}
|
||||
|
||||
public async Task<BotChannelCategory> 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<IEnumerable<BotChannel>> GetChannelsAsync(int botId, int? categoryId = null)
|
||||
{
|
||||
return await _botChannelRepository.GetForBotAsync(botId, categoryId);
|
||||
}
|
||||
|
||||
public async Task<BotChannel> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<BotTelegramService> _logger;
|
||||
|
||||
public BotTelegramService(
|
||||
BotManagementService botManagement,
|
||||
IConfiguration configuration,
|
||||
ILogger<BotTelegramService> logger)
|
||||
{
|
||||
_botManagement = botManagement;
|
||||
_configuration = configuration;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<int> 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<string> 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<string> 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<IReadOnlyDictionary<long, string>> ExportInviteLinksAsync(int botId, IReadOnlyList<long> channelTelegramIds, CancellationToken cancellationToken)
|
||||
{
|
||||
if (channelTelegramIds.Count == 0)
|
||||
return new Dictionary<long, string>();
|
||||
|
||||
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<TL.Channel>().Where(c => c.IsActive).ToDictionary(c => c.id, c => c);
|
||||
|
||||
var map = new Dictionary<long, string>();
|
||||
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<TL.Channel?> FindChannelAsync(Client client, long channelTelegramId)
|
||||
{
|
||||
var dialogs = await client.Messages_GetAllDialogs();
|
||||
return dialogs.chats.Values.OfType<TL.Channel>().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;
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,9 @@ public class AppDbContext : DbContext
|
||||
public DbSet<ChannelGroup> ChannelGroups => Set<ChannelGroup>();
|
||||
public DbSet<Group> Groups => Set<Group>();
|
||||
public DbSet<BatchTask> BatchTasks => Set<BatchTask>();
|
||||
public DbSet<Bot> Bots => Set<Bot>();
|
||||
public DbSet<BotChannel> BotChannels => Set<BotChannel>();
|
||||
public DbSet<BotChannelCategory> BotChannelCategories => Set<BotChannelCategory>();
|
||||
|
||||
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<Bot>(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<BotChannelCategory>(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<BotChannel>(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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace TelegramPanel.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Telegram 机器人(Bot)实体
|
||||
/// </summary>
|
||||
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<BotChannelCategory> Categories { get; set; } = new List<BotChannelCategory>();
|
||||
public ICollection<BotChannel> Channels { get; set; } = new List<BotChannel>();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace TelegramPanel.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Bot 加入的频道(用于管理“非创建者频道”)
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace TelegramPanel.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Bot 频道分类(独立于账号频道分类/分组)
|
||||
/// </summary>
|
||||
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<BotChannel> Channels { get; set; } = new List<BotChannel>();
|
||||
}
|
||||
|
||||
@@ -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<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
Name = table.Column<string>(type: "TEXT", maxLength: 100, nullable: false),
|
||||
Token = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
|
||||
Username = table.Column<string>(type: "TEXT", maxLength: 100, nullable: true),
|
||||
IsActive = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
LastSyncAt = table.Column<DateTime>(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<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
BotId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Name = table.Column<string>(type: "TEXT", maxLength: 100, nullable: false),
|
||||
Description = table.Column<string>(type: "TEXT", maxLength: 500, nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(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<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
BotId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
TelegramId = table.Column<long>(type: "INTEGER", nullable: false),
|
||||
AccessHash = table.Column<long>(type: "INTEGER", nullable: true),
|
||||
Title = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
|
||||
Username = table.Column<string>(type: "TEXT", maxLength: 100, nullable: true),
|
||||
IsBroadcast = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
MemberCount = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
About = table.Column<string>(type: "TEXT", maxLength: 1000, nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||
SyncedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
CategoryId = table.Column<int>(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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TelegramPanel.Data.Entities;
|
||||
|
||||
namespace TelegramPanel.Data.Repositories;
|
||||
|
||||
public class BotChannelCategoryRepository : Repository<BotChannelCategory>, IBotChannelCategoryRepository
|
||||
{
|
||||
public BotChannelCategoryRepository(AppDbContext context) : base(context)
|
||||
{
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<BotChannelCategory>> GetForBotAsync(int botId)
|
||||
{
|
||||
return await _dbSet
|
||||
.Where(x => x.BotId == botId)
|
||||
.OrderBy(x => x.Name)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<BotChannelCategory?> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TelegramPanel.Data.Entities;
|
||||
|
||||
namespace TelegramPanel.Data.Repositories;
|
||||
|
||||
public class BotChannelRepository : Repository<BotChannel>, IBotChannelRepository
|
||||
{
|
||||
public BotChannelRepository(AppDbContext context) : base(context)
|
||||
{
|
||||
}
|
||||
|
||||
public async Task<BotChannel?> GetByTelegramIdAsync(int botId, long telegramId)
|
||||
{
|
||||
return await _dbSet
|
||||
.Include(x => x.Category)
|
||||
.FirstOrDefaultAsync(x => x.BotId == botId && x.TelegramId == telegramId);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<BotChannel>> 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TelegramPanel.Data.Entities;
|
||||
|
||||
namespace TelegramPanel.Data.Repositories;
|
||||
|
||||
public class BotRepository : Repository<Bot>, IBotRepository
|
||||
{
|
||||
public BotRepository(AppDbContext context) : base(context)
|
||||
{
|
||||
}
|
||||
|
||||
public async Task<Bot?> 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<IEnumerable<Bot>> GetAllWithStatsAsync()
|
||||
{
|
||||
// 只用于列表显示统计信息,避免一次性加载全部频道详情
|
||||
return await _dbSet
|
||||
.Include(x => x.Channels)
|
||||
.Include(x => x.Categories)
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.ToListAsync();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
using TelegramPanel.Data.Entities;
|
||||
|
||||
namespace TelegramPanel.Data.Repositories;
|
||||
|
||||
public interface IBotChannelCategoryRepository : IRepository<BotChannelCategory>
|
||||
{
|
||||
Task<IEnumerable<BotChannelCategory>> GetForBotAsync(int botId);
|
||||
Task<BotChannelCategory?> GetByNameAsync(int botId, string name);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
using TelegramPanel.Data.Entities;
|
||||
|
||||
namespace TelegramPanel.Data.Repositories;
|
||||
|
||||
public interface IBotChannelRepository : IRepository<BotChannel>
|
||||
{
|
||||
Task<BotChannel?> GetByTelegramIdAsync(int botId, long telegramId);
|
||||
Task<IEnumerable<BotChannel>> GetForBotAsync(int botId, int? categoryId = null);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
using TelegramPanel.Data.Entities;
|
||||
|
||||
namespace TelegramPanel.Data.Repositories;
|
||||
|
||||
public interface IBotRepository : IRepository<Bot>
|
||||
{
|
||||
Task<Bot?> GetByNameAsync(string name);
|
||||
Task<IEnumerable<Bot>> GetAllWithStatsAsync();
|
||||
}
|
||||
|
||||
@@ -23,6 +23,9 @@ public static class ServiceCollectionExtensions
|
||||
services.AddScoped<IChannelGroupRepository, ChannelGroupRepository>();
|
||||
services.AddScoped<IGroupRepository, GroupRepository>();
|
||||
services.AddScoped<IBatchTaskRepository, BatchTaskRepository>();
|
||||
services.AddScoped<IBotRepository, BotRepository>();
|
||||
services.AddScoped<IBotChannelRepository, BotChannelRepository>();
|
||||
services.AddScoped<IBotChannelCategoryRepository, BotChannelCategoryRepository>();
|
||||
services.AddScoped(typeof(IRepository<>), typeof(Repository<>));
|
||||
|
||||
return services;
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
<Routes @rendermode="InteractiveServer" />
|
||||
<script src="_framework/blazor.web.js"></script>
|
||||
<script src="_content/MudBlazor/MudBlazor.min.js"></script>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -25,6 +25,10 @@
|
||||
<MudNavLink Href="/batch/admins" Icon="@Icons.Material.Filled.AdminPanelSettings">批量设管理</MudNavLink>
|
||||
</MudNavGroup>
|
||||
|
||||
<MudNavGroup Title="机器人管理" Icon="@Icons.Material.Filled.SmartToy">
|
||||
<MudNavLink Href="/bots" Icon="@Icons.Material.Filled.SmartToy">机器人列表</MudNavLink>
|
||||
</MudNavGroup>
|
||||
|
||||
<MudNavLink Href="/tasks" Icon="@Icons.Material.Filled.Assignment">
|
||||
任务中心
|
||||
</MudNavLink>
|
||||
|
||||
@@ -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
|
||||
|
||||
<PageTitle>Bot 频道 - Telegram Panel</PageTitle>
|
||||
|
||||
<MudText Typo="Typo.h4" Class="mb-2">Bot 频道</MudText>
|
||||
@if (bot != null)
|
||||
{
|
||||
<MudText Typo="Typo.subtitle2" Class="mb-4">@bot.Name @(!string.IsNullOrWhiteSpace(bot.Username) ? $"(@{bot.Username})" : "")</MudText>
|
||||
}
|
||||
|
||||
<MudCard>
|
||||
<MudCardContent>
|
||||
<MudTable Items="@filtered" Hover="true" Breakpoint="Breakpoint.Sm" Loading="@loading"
|
||||
MultiSelection="true" @bind-SelectedItems="selected">
|
||||
<ToolBarContent>
|
||||
<MudText Typo="Typo.h6">频道 (@channels.Count)</MudText>
|
||||
<MudSpacer />
|
||||
|
||||
<MudSelect @bind-Value="filterCategoryId" Label="分类" Variant="Variant.Outlined" Dense="true" Class="mr-2" Style="min-width: 160px;">
|
||||
<MudSelectItem Value="0">全部分类</MudSelectItem>
|
||||
<MudSelectItem Value="-1">未分类</MudSelectItem>
|
||||
@foreach (var c in categories)
|
||||
{
|
||||
<MudSelectItem Value="@c.Id">@c.Name</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
|
||||
<MudTextField @bind-Value="search" Placeholder="搜索频道..." Adornment="Adornment.Start"
|
||||
AdornmentIcon="@Icons.Material.Filled.Search" IconSize="Size.Medium" Class="mt-0"
|
||||
Immediate="true" />
|
||||
|
||||
<MudButton Variant="Variant.Outlined" Color="Color.Info" StartIcon="@Icons.Material.Filled.Sync" Class="ml-2"
|
||||
Disabled="@loading" OnClick="Sync">
|
||||
同步 Bot 频道
|
||||
</MudButton>
|
||||
|
||||
<MudButton Variant="Variant.Outlined" Color="Color.Success" StartIcon="@Icons.Material.Filled.Link" Class="ml-2"
|
||||
Disabled="@(loading || selected.Count == 0)" OnClick="ExportInvites">
|
||||
导出邀请(已选)
|
||||
</MudButton>
|
||||
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Refresh"
|
||||
Disabled="@loading" OnClick="LoadAll" Class="ml-2">
|
||||
刷新
|
||||
</MudButton>
|
||||
</ToolBarContent>
|
||||
|
||||
<HeaderContent>
|
||||
<MudTh>频道名称</MudTh>
|
||||
<MudTh>用户名</MudTh>
|
||||
<MudTh>分类</MudTh>
|
||||
<MudTh>最后同步</MudTh>
|
||||
<MudTh>操作</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="频道名称">
|
||||
<MudText Typo="Typo.body2"><strong>@context.Title</strong></MudText>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="用户名">
|
||||
@if (!string.IsNullOrWhiteSpace(context.Username))
|
||||
{
|
||||
<MudChip T="string" Size="Size.Small" Icon="@Icons.Material.Filled.AlternateEmail">@context.Username</MudChip>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>-</span>
|
||||
}
|
||||
</MudTd>
|
||||
<MudTd DataLabel="分类">
|
||||
<MudSelect T="int?" Value="@context.CategoryId" ValueChanged="@(async v => await SetCategoryAsync(context, v))"
|
||||
Dense="true" Variant="Variant.Outlined" Style="min-width: 160px;">
|
||||
<MudSelectItem Value="@(default(int?))">未分类</MudSelectItem>
|
||||
@foreach (var c in categories)
|
||||
{
|
||||
<MudSelectItem Value="@(c.Id)">@c.Name</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="最后同步">@context.SyncedAt.ToString("yyyy-MM-dd HH:mm")</MudTd>
|
||||
<MudTd DataLabel="操作">
|
||||
<MudMenu Icon="@Icons.Material.Filled.MoreVert" Size="Size.Small">
|
||||
<MudMenuItem Icon="@Icons.Material.Filled.Link" OnClick="@(async () => await CopyPublicLinkAsync(context))">复制链接</MudMenuItem>
|
||||
<MudMenuItem Icon="@Icons.Material.Filled.Key" OnClick="@(async () => await CopyInviteAsync(context))">复制邀请</MudMenuItem>
|
||||
</MudMenu>
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
<NoRecordsContent>
|
||||
<MudText>暂无频道</MudText>
|
||||
</NoRecordsContent>
|
||||
</MudTable>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
|
||||
<MudCard Class="mt-4">
|
||||
<MudCardContent>
|
||||
<MudText Typo="Typo.h6" Class="mb-2">新增分类</MudText>
|
||||
<MudGrid>
|
||||
<MudItem xs="12" md="4">
|
||||
<MudTextField @bind-Value="newCategoryName" Label="分类名称" Variant="Variant.Outlined" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" md="6">
|
||||
<MudTextField @bind-Value="newCategoryDesc" Label="说明(可选)" Variant="Variant.Outlined" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" md="2" Class="d-flex align-center">
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" Disabled="@loading"
|
||||
OnClick="@(async (MouseEventArgs _) => await CreateCategoryAsync())">
|
||||
创建
|
||||
</MudButton>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
|
||||
@code {
|
||||
[Parameter] public int BotId { get; set; }
|
||||
|
||||
private bool loading = true;
|
||||
private Bot? bot;
|
||||
private List<BotChannelCategory> categories = new();
|
||||
private List<BotChannel> channels = new();
|
||||
private HashSet<BotChannel> selected = new();
|
||||
|
||||
private int filterCategoryId = 0;
|
||||
private string search = "";
|
||||
|
||||
private string newCategoryName = "";
|
||||
private string newCategoryDesc = "";
|
||||
|
||||
private IEnumerable<BotChannel> 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
@page "/bots"
|
||||
@inject BotManagementService BotManagement
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IDialogService DialogService
|
||||
@inject NavigationManager Navigation
|
||||
@using TelegramPanel.Data.Entities
|
||||
|
||||
<PageTitle>机器人管理 - Telegram Panel</PageTitle>
|
||||
|
||||
<MudText Typo="Typo.h4" Class="mb-4">机器人管理</MudText>
|
||||
|
||||
<MudGrid>
|
||||
<MudItem xs="12" md="6">
|
||||
<MudCard>
|
||||
<MudCardContent>
|
||||
<MudText Typo="Typo.h6" Class="mb-2">新增机器人</MudText>
|
||||
<MudTextField @bind-Value="newName" Label="名称" Variant="Variant.Outlined" Required="true" />
|
||||
<MudTextField @bind-Value="newToken" Label="Bot Token" Variant="Variant.Outlined" Required="true" Class="mt-3" />
|
||||
<MudTextField @bind-Value="newUsername" Label="用户名(可选)" Variant="Variant.Outlined" Placeholder="bot_username" Class="mt-3" />
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" Disabled="@loading" Class="mt-3"
|
||||
OnClick="@(async (MouseEventArgs _) => await CreateBotAsync())">
|
||||
添加
|
||||
</MudButton>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
|
||||
<MudItem xs="12" md="12">
|
||||
<MudCard Class="mt-4">
|
||||
<MudCardContent>
|
||||
<MudTable Items="@bots" Hover="true" Breakpoint="Breakpoint.Sm" Loading="@loading">
|
||||
<ToolBarContent>
|
||||
<MudText Typo="Typo.h6">机器人 (@bots.Count)</MudText>
|
||||
<MudSpacer />
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Refresh"
|
||||
Disabled="@loading" OnClick="LoadBots">
|
||||
刷新
|
||||
</MudButton>
|
||||
</ToolBarContent>
|
||||
<HeaderContent>
|
||||
<MudTh>名称</MudTh>
|
||||
<MudTh>用户名</MudTh>
|
||||
<MudTh>频道数</MudTh>
|
||||
<MudTh>分类数</MudTh>
|
||||
<MudTh>最后同步</MudTh>
|
||||
<MudTh>状态</MudTh>
|
||||
<MudTh>操作</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="名称">@context.Name</MudTd>
|
||||
<MudTd DataLabel="用户名">@(!string.IsNullOrWhiteSpace(context.Username) ? $"@{context.Username}" : "-")</MudTd>
|
||||
<MudTd DataLabel="频道数">@context.Channels.Count</MudTd>
|
||||
<MudTd DataLabel="分类数">@context.Categories.Count</MudTd>
|
||||
<MudTd DataLabel="最后同步">@((context.LastSyncAt.HasValue ? context.LastSyncAt.Value.ToString("yyyy-MM-dd HH:mm") : "-"))</MudTd>
|
||||
<MudTd DataLabel="状态">
|
||||
<MudChip T="string" Size="Size.Small" Color="@(context.IsActive ? Color.Success : Color.Default)">
|
||||
@(context.IsActive ? "启用" : "停用")
|
||||
</MudChip>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="操作">
|
||||
<MudMenu Icon="@Icons.Material.Filled.MoreVert" Size="Size.Small">
|
||||
<MudMenuItem Icon="@Icons.Material.Filled.List"
|
||||
OnClick="@(() => Navigation.NavigateTo($"/bots/{context.Id}/channels"))">
|
||||
频道列表
|
||||
</MudMenuItem>
|
||||
<MudDivider />
|
||||
<MudMenuItem Icon="@Icons.Material.Filled.Delete" IconColor="Color.Error"
|
||||
OnClick="@(async () => await DeleteBotAsync(context))">
|
||||
删除
|
||||
</MudMenuItem>
|
||||
</MudMenu>
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
<NoRecordsContent>
|
||||
<MudText>暂无机器人</MudText>
|
||||
</NoRecordsContent>
|
||||
</MudTable>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
|
||||
@code {
|
||||
private bool loading = true;
|
||||
private List<Bot> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<long> 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<string>
|
||||
{
|
||||
$"# Bot: {bot.Name}",
|
||||
$"# ExportedAtUtc: {DateTime.UtcNow:O}",
|
||||
"# Format: <TelegramId>\\t<Link>",
|
||||
""
|
||||
};
|
||||
|
||||
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");
|
||||
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user