380 lines
15 KiB
C#
380 lines
15 KiB
C#
using Microsoft.EntityFrameworkCore;
|
|
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 IBotChannelMemberRepository _memberRepository;
|
|
private readonly ILogger<BotManagementService> _logger;
|
|
|
|
public BotManagementService(
|
|
IBotRepository botRepository,
|
|
IBotChannelRepository botChannelRepository,
|
|
IBotChannelCategoryRepository categoryRepository,
|
|
IBotChannelMemberRepository memberRepository,
|
|
ILogger<BotManagementService> logger)
|
|
{
|
|
_botRepository = botRepository;
|
|
_botChannelRepository = botChannelRepository;
|
|
_categoryRepository = categoryRepository;
|
|
_memberRepository = memberRepository;
|
|
_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 UpdateBotProfileAsync(int botId, string name, string? username, string? newToken)
|
|
{
|
|
name = (name ?? string.Empty).Trim();
|
|
username = string.IsNullOrWhiteSpace(username) ? null : username.Trim().TrimStart('@');
|
|
newToken = string.IsNullOrWhiteSpace(newToken) ? null : newToken.Trim();
|
|
|
|
if (botId <= 0)
|
|
throw new ArgumentException("BotId 无效", nameof(botId));
|
|
if (string.IsNullOrWhiteSpace(name))
|
|
throw new ArgumentException("机器人名称不能为空", nameof(name));
|
|
|
|
var bot = await _botRepository.GetByIdAsync(botId)
|
|
?? throw new InvalidOperationException($"机器人不存在:{botId}");
|
|
|
|
if (!string.Equals(bot.Name, name, StringComparison.Ordinal))
|
|
{
|
|
var existing = await _botRepository.GetByNameAsync(name);
|
|
if (existing != null && existing.Id != botId)
|
|
throw new InvalidOperationException("机器人名称已存在");
|
|
}
|
|
|
|
bot.Name = name;
|
|
bot.Username = username;
|
|
if (newToken != null)
|
|
bot.Token = newToken;
|
|
|
|
try
|
|
{
|
|
await _botRepository.UpdateAsync(bot);
|
|
}
|
|
catch (DbUpdateException ex)
|
|
{
|
|
var msg = GetInnermostMessage(ex);
|
|
if (msg.Contains("UNIQUE constraint failed: Bots.Name", StringComparison.OrdinalIgnoreCase))
|
|
throw new InvalidOperationException("机器人名称已存在");
|
|
|
|
if (msg.Contains("database is locked", StringComparison.OrdinalIgnoreCase)
|
|
|| msg.Contains("database is busy", StringComparison.OrdinalIgnoreCase)
|
|
|| msg.Contains("SQLite Error 5", StringComparison.OrdinalIgnoreCase))
|
|
throw new InvalidOperationException("数据库正被占用(SQLite database is locked),请稍后重试;若云端部署了多个实例共享同一个 sqlite 文件,请改为单实例或换用 MySQL/PostgreSQL。");
|
|
|
|
if (msg.Contains("no such table: Bots", StringComparison.OrdinalIgnoreCase)
|
|
|| msg.Contains("no such column", StringComparison.OrdinalIgnoreCase))
|
|
throw new InvalidOperationException($"数据库结构过旧或未完成迁移,请重启主程序以自动迁移数据库。详情:{msg}");
|
|
|
|
throw new InvalidOperationException($"更新机器人失败:{msg}", ex);
|
|
}
|
|
}
|
|
|
|
public async Task SetBotActiveStatusAsync(int botId, bool isActive)
|
|
{
|
|
var bot = await _botRepository.GetByIdAsync(botId);
|
|
if (bot == null)
|
|
return;
|
|
|
|
bot.IsActive = isActive;
|
|
await _botRepository.UpdateAsync(bot);
|
|
}
|
|
|
|
public async Task DeleteBotAsync(int id)
|
|
{
|
|
var bot = await _botRepository.GetByIdAsync(id);
|
|
if (bot == null)
|
|
return;
|
|
|
|
try
|
|
{
|
|
await _botRepository.DeleteAsync(bot);
|
|
}
|
|
catch (DbUpdateConcurrencyException)
|
|
{
|
|
// 视为已被删除(幂等)
|
|
}
|
|
}
|
|
|
|
public async Task<IEnumerable<BotChannelCategory>> GetCategoriesAsync()
|
|
{
|
|
return await _categoryRepository.GetAllOrderedAsync();
|
|
}
|
|
|
|
public async Task<BotChannelCategory> CreateCategoryAsync(string name, string? description = null)
|
|
{
|
|
name = (name ?? string.Empty).Trim();
|
|
description = string.IsNullOrWhiteSpace(description) ? null : description.Trim();
|
|
|
|
if (string.IsNullOrWhiteSpace(name))
|
|
throw new ArgumentException("分类名称不能为空", nameof(name));
|
|
|
|
var existing = await _categoryRepository.GetByNameAsync(name);
|
|
if (existing != null)
|
|
throw new InvalidOperationException("分类名称已存在");
|
|
|
|
var cat = new BotChannelCategory
|
|
{
|
|
Name = name,
|
|
Description = description,
|
|
CreatedAt = DateTime.UtcNow
|
|
};
|
|
|
|
try
|
|
{
|
|
return await _categoryRepository.AddAsync(cat);
|
|
}
|
|
catch (DbUpdateException ex)
|
|
{
|
|
var msg = GetInnermostMessage(ex);
|
|
if (msg.Contains("UNIQUE constraint failed: BotChannelCategories.Name", StringComparison.OrdinalIgnoreCase))
|
|
throw new InvalidOperationException("分类名称已存在");
|
|
|
|
if (msg.Contains("database is locked", StringComparison.OrdinalIgnoreCase)
|
|
|| msg.Contains("database is busy", StringComparison.OrdinalIgnoreCase)
|
|
|| msg.Contains("SQLite Error 5", StringComparison.OrdinalIgnoreCase))
|
|
throw new InvalidOperationException("数据库正被占用(SQLite database is locked),请稍后重试;若云端部署了多个实例共享同一个 sqlite 文件,请改为单实例或换用 MySQL/PostgreSQL。");
|
|
|
|
if (msg.Contains("no such table: BotChannelCategories", StringComparison.OrdinalIgnoreCase)
|
|
|| msg.Contains("no such column", StringComparison.OrdinalIgnoreCase)
|
|
|| msg.Contains("NOT NULL constraint failed: BotChannelCategories.BotId", StringComparison.OrdinalIgnoreCase))
|
|
throw new InvalidOperationException($"数据库结构过旧或未完成迁移,请重启主程序以自动迁移数据库。详情:{msg}");
|
|
|
|
throw new InvalidOperationException($"创建分类失败:{msg}", ex);
|
|
}
|
|
}
|
|
|
|
public async Task<BotChannelCategory> UpdateCategoryAsync(int categoryId, string name, string? description = null)
|
|
{
|
|
if (categoryId <= 0)
|
|
throw new ArgumentException("分类ID无效", nameof(categoryId));
|
|
|
|
name = (name ?? string.Empty).Trim();
|
|
description = string.IsNullOrWhiteSpace(description) ? null : description.Trim();
|
|
|
|
if (string.IsNullOrWhiteSpace(name))
|
|
throw new ArgumentException("分类名称不能为空", nameof(name));
|
|
|
|
var cat = await _categoryRepository.GetByIdAsync(categoryId);
|
|
if (cat == null)
|
|
throw new InvalidOperationException("分类不存在或已被删除");
|
|
|
|
if (!string.Equals(cat.Name, name, StringComparison.Ordinal))
|
|
{
|
|
var existing = await _categoryRepository.GetByNameAsync(name);
|
|
if (existing != null && existing.Id != cat.Id)
|
|
throw new InvalidOperationException("分类名称已存在");
|
|
}
|
|
|
|
cat.Name = name;
|
|
cat.Description = description;
|
|
|
|
try
|
|
{
|
|
await _categoryRepository.UpdateAsync(cat);
|
|
return cat;
|
|
}
|
|
catch (DbUpdateException ex)
|
|
{
|
|
var msg = GetInnermostMessage(ex);
|
|
if (msg.Contains("UNIQUE constraint failed: BotChannelCategories.Name", StringComparison.OrdinalIgnoreCase))
|
|
throw new InvalidOperationException("分类名称已存在");
|
|
|
|
if (msg.Contains("database is locked", StringComparison.OrdinalIgnoreCase)
|
|
|| msg.Contains("database is busy", StringComparison.OrdinalIgnoreCase)
|
|
|| msg.Contains("SQLite Error 5", StringComparison.OrdinalIgnoreCase))
|
|
throw new InvalidOperationException("数据库正被占用(SQLite database is locked),请稍后重试;若云端部署了多个实例共享同一个 sqlite 文件,请改为单实例或换用 MySQL/PostgreSQL。");
|
|
|
|
if (msg.Contains("no such table: BotChannelCategories", StringComparison.OrdinalIgnoreCase)
|
|
|| msg.Contains("no such column", StringComparison.OrdinalIgnoreCase))
|
|
throw new InvalidOperationException($"数据库结构过旧或未完成迁移,请重启主程序以自动迁移数据库。详情:{msg}");
|
|
|
|
throw new InvalidOperationException($"更新分类失败:{msg}", ex);
|
|
}
|
|
}
|
|
|
|
private static string GetInnermostMessage(Exception ex)
|
|
{
|
|
var cur = ex;
|
|
while (cur.InnerException != null)
|
|
cur = cur.InnerException;
|
|
return string.IsNullOrWhiteSpace(cur.Message) ? ex.Message : cur.Message;
|
|
}
|
|
|
|
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)
|
|
{
|
|
// Bot 频道列表仅展示“频道”(不展示群组/超级群组)
|
|
var list = await _botChannelRepository.GetForBotAsync(botId, categoryId);
|
|
return list.Where(x => x.IsBroadcast);
|
|
}
|
|
|
|
public async Task<(IReadOnlyList<BotChannel> Items, int TotalCount)> QueryChannelsPagedAsync(
|
|
int botId,
|
|
int? categoryId,
|
|
string? search,
|
|
int statusFilter,
|
|
int pageIndex,
|
|
int pageSize,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
return await _botChannelRepository.QueryPagedAsync(
|
|
botId: botId,
|
|
categoryId: categoryId,
|
|
broadcastOnly: true,
|
|
search: search,
|
|
statusFilter: statusFilter,
|
|
pageIndex: pageIndex,
|
|
pageSize: pageSize,
|
|
cancellationToken: cancellationToken);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 获取 Bot 可管理的聊天列表(包含频道/群组/超级群组)。
|
|
/// </summary>
|
|
public async Task<IEnumerable<BotChannel>> GetChatsAsync(int botId, int? categoryId = null)
|
|
{
|
|
return await _botChannelRepository.GetForBotAsync(botId, categoryId);
|
|
}
|
|
|
|
public async Task<BotChannel?> GetChannelByTelegramIdAsync(int botId, long telegramId)
|
|
{
|
|
return await _botChannelRepository.GetByTelegramIdAsync(botId, telegramId);
|
|
}
|
|
|
|
public async Task<BotChannel> UpsertChannelAsync(int botId, BotChannel channel)
|
|
{
|
|
if (botId <= 0)
|
|
throw new ArgumentException("BotId 无效", nameof(botId));
|
|
if (channel.TelegramId == 0)
|
|
throw new ArgumentException("TelegramId 无效", nameof(channel));
|
|
|
|
var now = DateTime.UtcNow;
|
|
|
|
var existing = await _botChannelRepository.GetGlobalByTelegramIdAsync(channel.TelegramId);
|
|
if (existing == null)
|
|
{
|
|
channel.SyncedAt = now;
|
|
existing = await _botChannelRepository.AddAsync(channel);
|
|
}
|
|
else
|
|
{
|
|
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 = now;
|
|
|
|
await _botChannelRepository.UpdateAsync(existing);
|
|
}
|
|
|
|
await _memberRepository.UpsertAsync(botId, existing.Id, now);
|
|
return existing;
|
|
}
|
|
|
|
public async Task DeleteChannelByTelegramIdAsync(int botId, long telegramId)
|
|
{
|
|
var ch = await _botChannelRepository.GetByTelegramIdAsync(botId, telegramId);
|
|
if (ch == null)
|
|
return;
|
|
|
|
await _memberRepository.DeleteAsync(botId, ch.Id);
|
|
|
|
// 清理无任何 Bot 关联的“孤儿频道”
|
|
var remains = await _memberRepository.CountForChannelAsync(ch.Id);
|
|
if (remains == 0)
|
|
await _botChannelRepository.DeleteAsync(ch);
|
|
}
|
|
|
|
public async Task UpdateChannelStatusAsync(int botId, long telegramId, bool ok, string? error, DateTime checkedAtUtc)
|
|
{
|
|
var ch = await _botChannelRepository.GetByTelegramIdAsync(botId, telegramId);
|
|
if (ch == null)
|
|
return;
|
|
|
|
ch.ChannelStatusOk = ok;
|
|
ch.ChannelStatusCheckedAtUtc = checkedAtUtc;
|
|
ch.ChannelStatusError = ok
|
|
? null
|
|
: string.IsNullOrWhiteSpace(error)
|
|
? "检测失败"
|
|
: error.Trim()[..Math.Min(error.Trim().Length, 500)];
|
|
|
|
await _botChannelRepository.UpdateAsync(ch);
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|