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 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> 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> 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() .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>> 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>> 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> 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; } 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> 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> 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> 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> 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(); 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; } 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> SendVoice( string token, [FromForm] BotSendAttachmentRequest request, CancellationToken cancellationToken) => SendAttachmentAsync(token, request, forceVoiceNote: true, requiredMediaKind: null, cancellationToken: cancellationToken); private async Task> 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; } 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 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> 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 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 NormalizeCommands(IReadOnlyList commands) { var normalized = new List(); var seen = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var command in commands ?? Array.Empty()) { 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; } }