Expand Ikar messaging and Android release support
This commit is contained in:
@@ -4,8 +4,10 @@ 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;
|
||||
@@ -23,8 +25,13 @@ public sealed class BotApiController(
|
||||
BotTokenService botTokenService,
|
||||
BotUpdateService botUpdateService,
|
||||
IHubContext<MessengerHub> hubContext,
|
||||
PushDispatchQueue pushDispatchQueue) : ControllerBase
|
||||
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)
|
||||
{
|
||||
@@ -205,10 +212,334 @@ public sealed class BotApiController(
|
||||
|
||||
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<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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
@@ -257,6 +588,112 @@ public sealed class BotApiController(
|
||||
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>();
|
||||
|
||||
Reference in New Issue
Block a user