Files
IKAR/ikar_server/src/Ikar.Server/Controllers/BotsController.cs
T

265 lines
9.4 KiB
C#

using System.Text.RegularExpressions;
using Ikar.Server.Data;
using Ikar.Server.Data.Entities;
using Ikar.Server.Infrastructure;
using Ikar.Server.Infrastructure.Auth;
using Ikar.Server.Infrastructure.Bots;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Ikar.Shared;
namespace Ikar.Server.Controllers;
[ApiController]
[Authorize]
[Route("api/[controller]")]
public sealed class BotsController(
IkarDbContext dbContext,
BotTokenService botTokenService,
BotDeletionService botDeletionService) : ControllerBase
{
private static readonly Regex BotUsernameRegex = new("^[A-Za-z0-9_]{5,64}$", RegexOptions.Compiled);
[HttpGet("mine")]
public async Task<ActionResult<IReadOnlyList<BotSummaryDto>>> GetMine(CancellationToken cancellationToken)
{
var ownerUserId = User.GetRequiredUserId();
var bots = await dbContext.Bots
.Include(x => x.User)
.Include(x => x.Commands)
.AsNoTracking()
.Where(x => x.OwnerUserId == ownerUserId)
.OrderBy(x => x.User.DisplayName)
.ToListAsync(cancellationToken);
return Ok(bots.Select(x => x.ToDto()).ToList());
}
[HttpGet("{botId:guid}")]
public async Task<ActionResult<BotSummaryDto>> GetBot(Guid botId, CancellationToken cancellationToken)
{
var bot = await LoadOwnedBotAsync(botId, User.GetRequiredUserId(), cancellationToken);
if (bot is null)
{
return NotFound();
}
return Ok(bot.ToDto());
}
[HttpPost]
public ActionResult<CreatedBotDto> CreateBot(CreateBotRequest request)
{
_ = request;
return StatusCode(
StatusCodes.Status410Gone,
"Создание ботов доступно только через чат с @botfatherbot. Напишите /создать_бота Имя бота | usernamebot | описание.");
}
[HttpPut("{botId:guid}")]
public async Task<ActionResult<BotSummaryDto>> UpdateBot(
Guid botId,
UpdateBotRequest request,
CancellationToken cancellationToken)
{
var ownerUserId = User.GetRequiredUserId();
var bot = await LoadOwnedBotAsync(botId, ownerUserId, cancellationToken);
if (bot is null)
{
return NotFound();
}
var validationError = await ValidateBotDefinitionAsync(
bot.User.Username,
request.DisplayName,
request.About,
excludingBotId: botId,
cancellationToken);
if (validationError is not null)
{
return ValidationProblem(validationError);
}
bot.User.DisplayName = request.DisplayName.Trim();
bot.User.About = string.IsNullOrWhiteSpace(request.About) ? null : request.About.Trim();
bot.IsEnabled = request.IsEnabled;
bot.UpdatedAt = DateTimeOffset.UtcNow;
await dbContext.SaveChangesAsync(cancellationToken);
return Ok(bot.ToDto());
}
[HttpPut("{botId:guid}/web-app")]
public async Task<IActionResult> SetWebApp(Guid botId, SetBotWebAppRequest request, CancellationToken cancellationToken)
{
var bot = await LoadOwnedBotAsync(botId, User.GetRequiredUserId(), cancellationToken);
if (bot is null) return NotFound();
var url = request.Url?.Trim();
if (!string.IsNullOrEmpty(url) && (url.Length > 2048 || !Uri.TryCreate(url, UriKind.Absolute, out var parsed) || parsed.Scheme != "https" || !string.IsNullOrEmpty(parsed.UserInfo)))
return BadRequest("Мини-приложение должно иметь адрес HTTPS.");
bot.WebAppUrl = string.IsNullOrEmpty(url) ? null : url;
bot.UpdatedAt = DateTimeOffset.UtcNow;
await dbContext.SaveChangesAsync(cancellationToken);
return Ok(new { url = bot.WebAppUrl });
}
[HttpPut("{botId:guid}/commands")]
public async Task<ActionResult<BotSummaryDto>> SetCommands(
Guid botId,
SetBotCommandsRequest request,
CancellationToken cancellationToken)
{
var ownerUserId = User.GetRequiredUserId();
var bot = await LoadOwnedBotAsync(botId, ownerUserId, cancellationToken);
if (bot is null)
{
return NotFound();
}
var normalizedCommands = NormalizeCommands(request.Commands);
var existingCommands = await dbContext.BotCommands
.Where(x => x.BotId == bot.Id)
.ToListAsync(cancellationToken);
dbContext.BotCommands.RemoveRange(existingCommands);
await dbContext.SaveChangesAsync(cancellationToken);
for (var index = 0; index < normalizedCommands.Count; index++)
{
dbContext.BotCommands.Add(new BotCommand
{
BotId = bot.Id,
Command = normalizedCommands[index].Command,
Description = normalizedCommands[index].Description,
SortOrder = index
});
}
bot.UpdatedAt = DateTimeOffset.UtcNow;
await dbContext.SaveChangesAsync(cancellationToken);
var reloaded = await LoadOwnedBotAsync(bot.Id, ownerUserId, cancellationToken)
?? throw new InvalidOperationException("Updated bot was not found.");
return Ok(reloaded.ToDto());
}
[HttpPost("{botId:guid}/regenerate-token")]
public async Task<ActionResult<RegenerateBotTokenResponseDto>> RegenerateToken(Guid botId, CancellationToken cancellationToken)
{
var ownerUserId = User.GetRequiredUserId();
var bot = await LoadOwnedBotAsync(botId, ownerUserId, cancellationToken);
if (bot is null)
{
return NotFound();
}
var rawToken = botTokenService.GenerateToken(bot.Id);
bot.TokenHash = botTokenService.HashToken(rawToken);
bot.UpdatedAt = DateTimeOffset.UtcNow;
await dbContext.SaveChangesAsync(cancellationToken);
return Ok(new RegenerateBotTokenResponseDto(rawToken));
}
[HttpDelete("{botId:guid}")]
public async Task<IActionResult> DeleteBot(Guid botId, CancellationToken cancellationToken)
{
var deleted = await botDeletionService.DeleteBotAsync(
botId,
User.GetRequiredUserId(),
cancellationToken);
if (deleted is null)
{
return NotFound();
}
return NoContent();
}
private async Task<Bot?> LoadOwnedBotAsync(Guid botId, Guid ownerUserId, CancellationToken cancellationToken)
{
return await dbContext.Bots
.Include(x => x.User)
.Include(x => x.Commands)
.SingleOrDefaultAsync(
x => x.Id == botId && x.OwnerUserId == ownerUserId,
cancellationToken);
}
private async Task<string?> ValidateBotDefinitionAsync(
string? username,
string? displayName,
string? about,
Guid? excludingBotId,
CancellationToken cancellationToken)
{
var trimmedUsername = username?.Trim() ?? string.Empty;
var trimmedDisplayName = displayName?.Trim() ?? string.Empty;
var normalizedUsername = trimmedUsername.ToUpperInvariant();
if (string.IsNullOrWhiteSpace(trimmedDisplayName))
{
return "Display name is required.";
}
if (trimmedDisplayName.Length > 120)
{
return "Display name must be 120 characters or fewer.";
}
if (!string.IsNullOrWhiteSpace(about) && about.Trim().Length > 70)
{
return "About must be 70 characters or fewer.";
}
if (!BotUsernameRegex.IsMatch(trimmedUsername) ||
!trimmedUsername.EndsWith("bot", StringComparison.OrdinalIgnoreCase))
{
return "Bot username must contain only latin letters, digits or underscore and end with 'bot'.";
}
var usernameTaken = await dbContext.Users
.AsNoTracking()
.Include(x => x.BotProfile)
.AnyAsync(
x => x.NormalizedUsername == normalizedUsername &&
(!excludingBotId.HasValue || x.BotProfile == null || x.BotProfile.Id != excludingBotId.Value),
cancellationToken);
if (usernameTaken)
{
return "Username is already taken.";
}
return null;
}
private static List<BotCommandDto> NormalizeCommands(IReadOnlyList<BotCommandDto> commands)
{
var normalized = new List<BotCommandDto>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var command in commands ?? Array.Empty<BotCommandDto>())
{
var normalizedCommand = (command.Command ?? string.Empty).Trim().TrimStart('/');
var normalizedDescription = (command.Description ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(normalizedCommand) || string.IsNullOrWhiteSpace(normalizedDescription))
{
continue;
}
if (!Regex.IsMatch(normalizedCommand, "^[a-z0-9_]{1,32}$", RegexOptions.IgnoreCase))
{
continue;
}
if (!seen.Add(normalizedCommand))
{
continue;
}
normalized.Add(new BotCommandDto(normalizedCommand.ToLowerInvariant(), normalizedDescription));
}
return normalized;
}
}