265 lines
9.4 KiB
C#
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;
|
|
}
|
|
}
|