Files
IKAR/src/Ikar.Server/Controllers/BotApiController.cs
T

730 lines
26 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 Ikar.Server.Infrastructure.Channels;
using Ikar.Server.Infrastructure.Hubs;
using Ikar.Server.Infrastructure.Push;
using Ikar.Server.Infrastructure.Storage;
using Ikar.Shared;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
namespace Ikar.Server.Controllers;
[ApiController]
[AllowAnonymous]
[Route("api/bot/{token}")]
public sealed class BotApiController(
IkarDbContext dbContext,
PresenceTracker presenceTracker,
BotTokenService botTokenService,
BotUpdateService botUpdateService,
IHubContext<MessengerHub> hubContext,
PushDispatchQueue pushDispatchQueue,
AttachmentStorageService attachmentStorageService) : ControllerBase
{
private const long MaxBotAttachmentSizeBytes = 25 * 1024 * 1024;
private const long MaxBotMediaGroupSizeBytes = 100 * 1024 * 1024;
private const int MaxBotMediaGroupAttachments = 10;
[HttpGet("getMe")]
public async Task<ActionResult<UserSummaryDto>> GetMe(string token, CancellationToken cancellationToken)
{
var bot = await LoadBotByTokenAsync(token, cancellationToken);
if (bot is null)
{
return Unauthorized();
}
if (!bot.IsEnabled)
{
return StatusCode(StatusCodes.Status423Locked, "Bot is disabled.");
}
return Ok(bot.User.ToDto(presenceTracker));
}
[HttpGet("getUpdates")]
public async Task<ActionResult<BotUpdatesResponseDto>> GetUpdates(
string token,
[FromQuery] long offset = 0,
[FromQuery] int limit = 100,
[FromQuery] int timeoutSeconds = 20,
CancellationToken cancellationToken = default)
{
var bot = await LoadBotByTokenAsync(token, cancellationToken);
if (bot is null)
{
return Unauthorized();
}
if (!bot.IsEnabled)
{
return StatusCode(StatusCodes.Status423Locked, "Bot is disabled.");
}
limit = Math.Clamp(limit, 1, 100);
timeoutSeconds = Math.Clamp(timeoutSeconds, 0, 50);
var timeoutAt = DateTimeOffset.UtcNow.AddSeconds(timeoutSeconds);
while (true)
{
var updates = await LoadUpdatesAsync(bot, offset, limit, cancellationToken);
if (updates.Count > 0 || timeoutSeconds == 0 || DateTimeOffset.UtcNow >= timeoutAt)
{
var messages = updates
.Select(x => x.Message)
.Where(x => x is not null)
.Cast<Message>()
.ToList();
if (messages.Count > 0)
{
await botUpdateService.MarkMessagesReadByBotAsync(bot.UserId, messages, cancellationToken);
}
var payload = updates
.Select(update => new BotUpdateDto(
update.Id,
BotUpdateKind.Message,
update.Message?.ToDto(presenceTracker, bot.UserId)))
.ToList();
var nextOffset = payload.Count == 0 ? offset : payload[^1].Id + 1;
return Ok(new BotUpdatesResponseDto(nextOffset, payload));
}
await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
}
}
[HttpGet("getMyCommands")]
public async Task<ActionResult<IReadOnlyList<BotCommandDto>>> GetMyCommands(string token, CancellationToken cancellationToken)
{
var bot = await LoadBotByTokenAsync(token, cancellationToken);
if (bot is null)
{
return Unauthorized();
}
return Ok(bot.Commands
.OrderBy(x => x.SortOrder)
.ThenBy(x => x.Command, StringComparer.OrdinalIgnoreCase)
.Select(x => new BotCommandDto(x.Command, x.Description))
.ToList());
}
[HttpPost("setMyCommands")]
public async Task<ActionResult<IReadOnlyList<BotCommandDto>>> SetMyCommands(
string token,
SetBotCommandsRequest request,
CancellationToken cancellationToken)
{
var bot = await LoadBotByTokenAsync(token, cancellationToken);
if (bot is null)
{
return Unauthorized();
}
if (!bot.IsEnabled)
{
return StatusCode(StatusCodes.Status423Locked, "Bot is disabled.");
}
var commands = 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 < commands.Count; index++)
{
dbContext.BotCommands.Add(new BotCommand
{
BotId = bot.Id,
Command = commands[index].Command,
Description = commands[index].Description,
SortOrder = index
});
}
bot.UpdatedAt = DateTimeOffset.UtcNow;
await dbContext.SaveChangesAsync(cancellationToken);
return Ok(commands);
}
[HttpPost("sendMessage")]
public async Task<ActionResult<MessageDto>> SendMessage(
string token,
BotSendMessageRequest request,
CancellationToken cancellationToken)
{
var bot = await LoadBotByTokenAsync(token, cancellationToken);
if (bot is null)
{
return Unauthorized();
}
if (!bot.IsEnabled)
{
return StatusCode(StatusCodes.Status423Locked, "Bot is disabled.");
}
var text = request.Text?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(text))
{
return ValidationProblem("Message text is required.");
}
var chat = await dbContext.Chats
.Include(x => x.Members)
.SingleOrDefaultAsync(
x => x.Id == request.ChatId && x.Members.Any(member => member.UserId == bot.UserId),
cancellationToken);
if (chat is null)
{
return NotFound("Chat not found.");
}
if (!DtoMapper.BuildPermissions(chat.Members.FirstOrDefault(member => member.UserId == bot.UserId), chat.Type).CanPostMessages)
{
return StatusCode(StatusCodes.Status403Forbidden, "Bot cannot publish in this chat.");
}
var message = new Message
{
ChatId = chat.Id,
Chat = chat,
SenderId = bot.UserId,
Sender = bot.User,
PostedAsChannel = chat.Type == ChatType.Channel,
AuthorSignature = chat.Type == ChatType.Channel && chat.ChannelSignaturesEnabled
? string.IsNullOrWhiteSpace(bot.User.DisplayName) ? bot.User.Username : bot.User.DisplayName
: null,
Text = text,
SentAt = DateTimeOffset.UtcNow
};
chat.LastActivityAt = message.SentAt;
dbContext.Messages.Add(message);
var discussionRootMessage = await ChannelDiscussionMessageFactory.CreateLinkedDiscussionRootAsync(
dbContext,
attachmentStorageService,
chat,
message,
bot.User,
message.SentAt,
cancellationToken);
if (discussionRootMessage is not null)
{
message.DiscussionMessageId = discussionRootMessage.Id;
}
pushDispatchQueue.StageMessages(dbContext, message, discussionRootMessage);
await dbContext.SaveChangesAsync(cancellationToken);
await NotifyMessageCreatedAsync(chat, message, cancellationToken);
await pushDispatchQueue.EnqueueAsync(chat.Id, message.Id);
if (discussionRootMessage is not null)
{
await NotifyMessageCreatedAsync(discussionRootMessage.Chat, discussionRootMessage, cancellationToken);
await pushDispatchQueue.EnqueueAsync(discussionRootMessage.ChatId, discussionRootMessage.Id);
}
return Ok(message.ToDto(presenceTracker, bot.UserId));
}
[HttpPost("sendDocument")]
[RequestFormLimits(MultipartBodyLengthLimit = MaxBotAttachmentSizeBytes)]
[RequestSizeLimit(MaxBotAttachmentSizeBytes)]
public Task<ActionResult<MessageDto>> SendDocument(
string token,
[FromForm] BotSendAttachmentRequest request,
CancellationToken cancellationToken) =>
SendAttachmentAsync(token, request, forceVoiceNote: false, requiredMediaKind: null, cancellationToken: cancellationToken);
[HttpPost("sendPhoto")]
[RequestFormLimits(MultipartBodyLengthLimit = MaxBotAttachmentSizeBytes)]
[RequestSizeLimit(MaxBotAttachmentSizeBytes)]
public Task<ActionResult<MessageDto>> SendPhoto(
string token,
[FromForm] BotSendAttachmentRequest request,
CancellationToken cancellationToken) =>
SendAttachmentAsync(token, request, forceVoiceNote: false, requiredMediaKind: BotAttachmentMediaKind.Photo, cancellationToken: cancellationToken);
[HttpPost("sendVideo")]
[RequestFormLimits(MultipartBodyLengthLimit = MaxBotAttachmentSizeBytes)]
[RequestSizeLimit(MaxBotAttachmentSizeBytes)]
public Task<ActionResult<MessageDto>> SendVideo(
string token,
[FromForm] BotSendAttachmentRequest request,
CancellationToken cancellationToken) =>
SendAttachmentAsync(token, request, forceVoiceNote: false, requiredMediaKind: BotAttachmentMediaKind.Video, cancellationToken: cancellationToken);
[HttpPost("sendMediaGroup")]
[RequestFormLimits(MultipartBodyLengthLimit = MaxBotMediaGroupSizeBytes)]
[RequestSizeLimit(MaxBotMediaGroupSizeBytes)]
public async Task<ActionResult<MessageDto>> SendMediaGroup(
string token,
[FromForm] BotSendMediaGroupRequest request,
CancellationToken cancellationToken)
{
var bot = await LoadBotByTokenAsync(token, cancellationToken);
if (bot is null)
{
return Unauthorized();
}
if (!bot.IsEnabled)
{
return StatusCode(StatusCodes.Status423Locked, "Bot is disabled.");
}
if (request.ChatId == Guid.Empty)
{
return ValidationProblem("ChatId is required.");
}
var files = request.Files
.Where(file => file is not null)
.ToList();
if (files.Count < 2)
{
return BadRequest("Media group must contain at least 2 files.");
}
if (files.Count > MaxBotMediaGroupAttachments)
{
return BadRequest($"Media group can contain up to {MaxBotMediaGroupAttachments} files.");
}
if (files.Any(file => file.Length <= 0))
{
return BadRequest("Media group contains an empty file.");
}
if (files.Any(file => file.Length > MaxBotAttachmentSizeBytes) || files.Sum(file => file.Length) > MaxBotMediaGroupSizeBytes)
{
return BadRequest($"Media group exceeds {MaxBotMediaGroupSizeBytes} bytes or contains a file over {MaxBotAttachmentSizeBytes} bytes.");
}
if (files.Any(file =>
!MatchesRequiredMediaKind(file, BotAttachmentMediaKind.Photo) &&
!MatchesRequiredMediaKind(file, BotAttachmentMediaKind.Video)))
{
return BadRequest("sendMediaGroup accepts only image and video files.");
}
var chat = await dbContext.Chats
.Include(x => x.Members)
.SingleOrDefaultAsync(
x => x.Id == request.ChatId && x.Members.Any(member => member.UserId == bot.UserId),
cancellationToken);
if (chat is null)
{
return NotFound("Chat not found.");
}
if (!DtoMapper.BuildPermissions(chat.Members.FirstOrDefault(member => member.UserId == bot.UserId), chat.Type).CanPostMessages)
{
return StatusCode(StatusCodes.Status403Forbidden, "Bot cannot publish in this chat.");
}
var sentAt = DateTimeOffset.UtcNow;
var message = new Message
{
ChatId = chat.Id,
Chat = chat,
SenderId = bot.UserId,
Sender = bot.User,
PostedAsChannel = chat.Type == ChatType.Channel,
AuthorSignature = chat.Type == ChatType.Channel && chat.ChannelSignaturesEnabled
? string.IsNullOrWhiteSpace(bot.User.DisplayName) ? bot.User.Username : bot.User.DisplayName
: null,
Text = request.Text?.Trim() ?? string.Empty,
MediaAlbumId = Guid.NewGuid(),
SentAt = sentAt
};
var savedAttachments = new List<MessageAttachment>();
Message? discussionRootMessage = null;
try
{
for (var index = 0; index < files.Count; index++)
{
var attachment = await attachmentStorageService.SaveAsync(message.Id, files[index], cancellationToken, sortOrder: index);
savedAttachments.Add(attachment);
message.Attachments.Add(attachment);
}
chat.LastActivityAt = message.SentAt;
dbContext.Messages.Add(message);
discussionRootMessage = await ChannelDiscussionMessageFactory.CreateLinkedDiscussionRootAsync(
dbContext,
attachmentStorageService,
chat,
message,
bot.User,
sentAt,
cancellationToken);
if (discussionRootMessage is not null)
{
message.DiscussionMessageId = discussionRootMessage.Id;
}
pushDispatchQueue.StageMessages(dbContext, message, discussionRootMessage);
await dbContext.SaveChangesAsync(cancellationToken);
}
catch
{
foreach (var attachment in savedAttachments)
{
attachmentStorageService.DeleteIfExists(attachment.StoragePath);
}
if (discussionRootMessage is not null)
{
DeleteFiles(discussionRootMessage.Attachments.Select(x => x.StoragePath));
}
throw;
}
await NotifyMessageCreatedAsync(chat, message, cancellationToken);
await pushDispatchQueue.EnqueueAsync(chat.Id, message.Id);
if (discussionRootMessage is not null)
{
await NotifyMessageCreatedAsync(discussionRootMessage.Chat, discussionRootMessage, cancellationToken);
await pushDispatchQueue.EnqueueAsync(discussionRootMessage.ChatId, discussionRootMessage.Id);
}
return Ok(message.ToDto(presenceTracker, bot.UserId));
}
[HttpPost("sendVoice")]
[RequestFormLimits(MultipartBodyLengthLimit = MaxBotAttachmentSizeBytes)]
[RequestSizeLimit(MaxBotAttachmentSizeBytes)]
public Task<ActionResult<MessageDto>> SendVoice(
string token,
[FromForm] BotSendAttachmentRequest request,
CancellationToken cancellationToken) =>
SendAttachmentAsync(token, request, forceVoiceNote: true, requiredMediaKind: null, cancellationToken: cancellationToken);
private async Task<ActionResult<MessageDto>> SendAttachmentAsync(
string token,
BotSendAttachmentRequest request,
bool forceVoiceNote,
BotAttachmentMediaKind? requiredMediaKind,
CancellationToken cancellationToken)
{
var bot = await LoadBotByTokenAsync(token, cancellationToken);
if (bot is null)
{
return Unauthorized();
}
if (!bot.IsEnabled)
{
return StatusCode(StatusCodes.Status423Locked, "Bot is disabled.");
}
if (request.ChatId == Guid.Empty)
{
return ValidationProblem("ChatId is required.");
}
if (request.File is null)
{
return BadRequest("File is required.");
}
if (request.File.Length <= 0)
{
return BadRequest("File is empty.");
}
if (request.File.Length > MaxBotAttachmentSizeBytes)
{
return BadRequest($"File exceeds {MaxBotAttachmentSizeBytes} bytes.");
}
if (requiredMediaKind is not null && !MatchesRequiredMediaKind(request.File, requiredMediaKind.Value))
{
return BadRequest(requiredMediaKind == BotAttachmentMediaKind.Photo
? "sendPhoto accepts only image files."
: "sendVideo accepts only video files.");
}
var chat = await dbContext.Chats
.Include(x => x.Members)
.SingleOrDefaultAsync(
x => x.Id == request.ChatId && x.Members.Any(member => member.UserId == bot.UserId),
cancellationToken);
if (chat is null)
{
return NotFound("Chat not found.");
}
if (!DtoMapper.BuildPermissions(chat.Members.FirstOrDefault(member => member.UserId == bot.UserId), chat.Type).CanPostMessages)
{
return StatusCode(StatusCodes.Status403Forbidden, "Bot cannot publish in this chat.");
}
var sentAt = DateTimeOffset.UtcNow;
var message = new Message
{
ChatId = chat.Id,
Chat = chat,
SenderId = bot.UserId,
Sender = bot.User,
PostedAsChannel = chat.Type == ChatType.Channel,
AuthorSignature = chat.Type == ChatType.Channel && chat.ChannelSignaturesEnabled
? string.IsNullOrWhiteSpace(bot.User.DisplayName) ? bot.User.Username : bot.User.DisplayName
: null,
Text = request.Text?.Trim() ?? string.Empty,
SentAt = sentAt
};
MessageAttachment? attachment = null;
Message? discussionRootMessage = null;
try
{
attachment = await attachmentStorageService.SaveAsync(message.Id, request.File, cancellationToken);
if (forceVoiceNote || IsVoiceLikeAttachment(attachment))
{
attachment.ContentType = ResolveVoiceContentType(attachment);
attachment.Kind = AttachmentKind.VoiceNote;
}
message.Attachments.Add(attachment);
chat.LastActivityAt = message.SentAt;
dbContext.Messages.Add(message);
discussionRootMessage = await ChannelDiscussionMessageFactory.CreateLinkedDiscussionRootAsync(
dbContext,
attachmentStorageService,
chat,
message,
bot.User,
sentAt,
cancellationToken);
if (discussionRootMessage is not null)
{
message.DiscussionMessageId = discussionRootMessage.Id;
}
pushDispatchQueue.StageMessages(dbContext, message, discussionRootMessage);
await dbContext.SaveChangesAsync(cancellationToken);
}
catch
{
if (attachment is not null)
{
attachmentStorageService.DeleteIfExists(attachment.StoragePath);
}
if (discussionRootMessage is not null)
{
DeleteFiles(discussionRootMessage.Attachments.Select(x => x.StoragePath));
}
throw;
}
await NotifyMessageCreatedAsync(chat, message, cancellationToken);
await pushDispatchQueue.EnqueueAsync(chat.Id, message.Id);
if (discussionRootMessage is not null)
{
await NotifyMessageCreatedAsync(discussionRootMessage.Chat, discussionRootMessage, cancellationToken);
await pushDispatchQueue.EnqueueAsync(discussionRootMessage.ChatId, discussionRootMessage.Id);
}
return Ok(message.ToDto(presenceTracker, bot.UserId));
}
private async Task<Bot?> LoadBotByTokenAsync(string token, CancellationToken cancellationToken)
{
var tokenHash = botTokenService.HashToken(token);
return await dbContext.Bots
.Include(x => x.User)
.ThenInclude(x => x.Sessions)
.Include(x => x.Commands)
.SingleOrDefaultAsync(x => x.TokenHash == tokenHash, cancellationToken);
}
private async Task<List<BotUpdate>> LoadUpdatesAsync(Bot bot, long offset, int limit, CancellationToken cancellationToken)
{
return await dbContext.BotUpdates
.AsNoTracking()
.Where(x => x.BotId == bot.Id && x.Id >= offset)
.OrderBy(x => x.Id)
.Take(limit)
.Include(x => x.Message)
.ThenInclude(x => x.Sender)
.ThenInclude(x => x.Sessions)
.Include(x => x.Message)
.ThenInclude(x => x.Attachments)
.Include(x => x.Message)
.ThenInclude(x => x.Reactions)
.Include(x => x.Message)
.ThenInclude(x => x.Views)
.Include(x => x.Message)
.ThenInclude(x => x.Chat)
.ThenInclude(x => x.Members)
.ToListAsync(cancellationToken);
}
private Task NotifyMessageCreatedAsync(Chat chat, Message message, CancellationToken cancellationToken)
{
var tasks = chat.Members.Select(async member =>
{
var dto = message.ToDto(presenceTracker, member.UserId);
await hubContext.Clients.User(member.UserId.ToString())
.SendAsync("MessageCreated", dto, cancellationToken);
});
return Task.WhenAll(tasks);
}
private void DeleteFiles(IEnumerable<string> attachmentPaths)
{
foreach (var path in attachmentPaths)
{
attachmentStorageService.DeleteIfExists(path);
}
}
private static string ResolveVoiceContentType(MessageAttachment attachment)
{
var normalized = attachment.ContentType
.Split(';', 2, StringSplitOptions.TrimEntries)
.FirstOrDefault();
if (!string.IsNullOrWhiteSpace(normalized) &&
normalized.StartsWith("audio/", StringComparison.OrdinalIgnoreCase))
{
return attachment.ContentType;
}
var extension = Path.GetExtension(attachment.OriginalFileName).ToLowerInvariant();
if (string.IsNullOrWhiteSpace(extension))
{
var lastToken = attachment.OriginalFileName
.Split([' ', '.', '_', '-', '(', ')', '[', ']'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.LastOrDefault()
?.ToLowerInvariant();
extension = string.IsNullOrWhiteSpace(lastToken) ? string.Empty : $".{lastToken}";
}
return extension switch
{
".aac" => "audio/aac",
".flac" => "audio/flac",
".m4a" => "audio/mp4",
".mp3" => "audio/mpeg",
".oga" or ".ogg" => "audio/ogg",
".opus" => "audio/opus",
".wav" => "audio/wav",
".webm" => "audio/webm",
_ => "audio/ogg"
};
}
private static bool IsVoiceLikeAttachment(MessageAttachment attachment)
{
var normalized = attachment.ContentType
.Split(';', 2, StringSplitOptions.TrimEntries)
.FirstOrDefault()
?.Trim()
.ToLowerInvariant();
if (normalized is "audio/ogg" or "audio/opus" or "application/ogg" or "application/opus" or "application/x-ogg" or "video/ogg")
{
return true;
}
var extension = Path.GetExtension(attachment.OriginalFileName).ToLowerInvariant();
if (string.IsNullOrWhiteSpace(extension))
{
var lastToken = attachment.OriginalFileName
.Split([' ', '.', '_', '-', '(', ')', '[', ']'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.LastOrDefault()
?.ToLowerInvariant();
extension = string.IsNullOrWhiteSpace(lastToken) ? string.Empty : $".{lastToken}";
}
return extension is ".ogg" or ".oga" or ".opus";
}
private static bool MatchesRequiredMediaKind(IFormFile file, BotAttachmentMediaKind requiredMediaKind)
{
var normalizedContentType = file.ContentType
?.Split(';', 2, StringSplitOptions.TrimEntries)
.FirstOrDefault()
?.Trim()
.ToLowerInvariant();
if (!string.IsNullOrWhiteSpace(normalizedContentType))
{
if (requiredMediaKind == BotAttachmentMediaKind.Photo &&
normalizedContentType.StartsWith("image/", StringComparison.Ordinal))
{
return true;
}
if (requiredMediaKind == BotAttachmentMediaKind.Video &&
normalizedContentType.StartsWith("video/", StringComparison.Ordinal))
{
return true;
}
}
var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
return requiredMediaKind switch
{
BotAttachmentMediaKind.Photo => extension is ".jpg" or ".jpeg" or ".png" or ".gif" or ".webp" or ".bmp",
BotAttachmentMediaKind.Video => extension is ".mp4" or ".m4v" or ".mov" or ".webm" or ".mkv",
_ => false
};
}
private enum BotAttachmentMediaKind
{
Photo,
Video
}
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;
}
}