730 lines
26 KiB
C#
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;
|
|
}
|
|
}
|